Image

progressive-web-apps-vue-3

9 min read
Last update: December 19, 2021

Object Stores with IndexedDB

When it comes to IndexedDB databases, they all contain specific “object stores” which are the next critical step in our journey.

What are object stores?

Within a database, object stores are specific tables that contain different types of data based on whatever you define. For example, if we had a “grocery” database, you could contain all of the items in a single “product” store. On the other hand, you might want multiple object stores like “produce,” “bakery,” “drinks,” etc."

In the case of our app, we’ll keep things simple. Since our database is “todomvc,” this means that we’ll have an object store of “todos.” With that decided, it’s time to create our object store!

When should object stores be created?

When a database is created, we will leverage an additional lifecycle hook called onupgradeneeded. Though the naming doesn’t make this immediately obvious, this lifecycle hook is called when the database is initially created or upgraded to a new version, which makes it the perfect lifecycle hook to create our object store.

request.onupgradeneeded = event => {
let database = event.target.result
// Where we will create our object store
}

How to create an object store?

Inside of the onupgradeneeded hook, we’ll call the createObjectStore method on our database to initiate the transaction.

request.onupgradeneeded = event => {
let database = event.target.result
database.createObjectStore()
}

The method takes two parameters:

  1. Object Store Name - This is a string that we define that must be unique in every database, in our case it’ll be todos
  2. Optional Parameters - An object that allows us to configure two aspects:
  3. Auto Increment - Whether each new item automatically increments, which is useful so we will set this to true
  4. Key Path - Defines where the database should look for the unique key of each item. In our case, each todo item will be an object with an id key, so that’s what we will use
request.onupgradeneeded = event => {
let database = event.target.result
database.createObjectStore('todos', {
autoIncrement: true,
keyPath: 'id'
})
}

For more information on createObjectStore, be sure to check out the official MDN docs here.

Screen Shot 2021-10-20 at 9.34.17 AM (2).png

Now, when we delete our old database and refresh our app, you’ll see that inside of our todomvcdb database, we see our new todos object store!

Creating a Reusable Method to Fetch an Object Store in Our Database

Now that we are able to create an object store, it’s time to learn how to fetch data from our object store. To start, we’ll create a reusable method called getTodoStore.

export default {
// Other code is excluded for brevity
methods: {
getTodoStore() {}
}
}

Inside our method, we’ll start by using our getDatabase method we created in the last lesson since we want to always get the latest database when retrieving our todo object store.

export default {
// Other code is excluded for brevity
methods: {
getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase
}
}
}

And similar to the getDatabase method that we wrote, it’s time for us now to utilize JavaScript Promises again since this is an asynchronous operation. So let’s start by scaffolding that pattern out while updating our function to be an async one as well.

export default {
// Other code is excluded for brevity
methods: {
async getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase

return new Promise((resolve, reject) => {

}
}
}
}

Initiate a transaction to read the object stores

With our new Promise scaffold, we need to initiate a request to the database to read the object store todos that we created. To accomplish this, this utilizes a concept in databases called a transaction, which also happens to be the method name we will call as well.

The method takes two parameters:

  1. Object Store Name(s) - If you are only requesting one store, then you can pass a string which we will do via todos. Otherwise you can pass an array of strings for multiple object stores.
  2. Permission Level - Without getting into details, the basic permissions consist of the ability or inability to read and/or write data. Since we are only fetching data, the permission we will be using is readonly.
export default {
// Other code is excluded for brevity
methods: {
async getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase

return new Promise((resolve, reject) => {
let transaction = db.transaction('todos', 'readonly')
}
}
}
}

Request the object store from the transaction

Once we have initiated our transaction to read the todos object store from our database, we can now access our object store directly via the objectStore method which takes the name of the object store we want to read (i.e., todos). And while this may seem odd at first, remember that you can request multiple object stores in a single transaction.

export default {
// Other code is excluded for brevity
methods: {
async getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase

return new Promise((resolve, reject) => {
const transaction = db.transaction('todos', 'readonly')
const store = transaction.objectStore('todos')
}
}
}
}

Looping through events through a “cursor”

Now we’ve got to fetch the data from the store. If you’re new to databases, this next part may seem a little odd, but just trust the process. And the reason it may feel a bit weird is that we have to manually iterate through the object store using the idea of a “cursor.”

To understand this, let’s start by showing the code required to loop through the data:

// Other code excluded for brevity
const transaction = db.transaction('todos', 'readonly')
const store = transaction.objectStore('todos')

// Define a place to store the data temporarily before returning it
let todoList = []

// Iterate through the object store to read and store items in our todoList
store.openCursor().onsuccess = event => {
let cursor = event.target.result
if (cursor) {
todoList.push(cursor.value)
cursor.continue()
}
}

The way to think about “cursors” are like the mouse cursor you use to point and click on the screen in order to determine where you are on the screen, but rather than tracking screen position, the database is tracking what position it’s currently on within the table. So just like how when I move my cursor in VS Code it knows which line number I’m on, cursors allow databases to know what row it is on.

As a result, we need to use the openCursor method on our object store to start the process. And using the onsuccess lifecycle hook to verify that it is successful, we can write a function which receives the event as a default parameter. And if there is data in that cursor, we will save the result and push it to our todoList variable that will temporarily hold our data before we return it from the Promise. Once that’s complete, we call the continue method to allow the database to resume iterating through its table until its complete.

And so, when we put it all together, it should look like the following:

export default {
// Other code is excluded for brevity
methods: {
async getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase

return new Promise((resolve, reject) => {
const transaction = db.transaction('todos', 'readonly')
const store = transaction.objectStore('todos')
let todoList = []

// Iterate through data using cursors
store.openCursor().onsuccess = event => {
let cursor = event.target.result
if (cursor) {
todoList.push(cursor.value)
cursor.continue()
}
}
}
}
}
}

Return the appropriate response from our promise

Our last step for this lesson is resolving our promise based on what happens. And the way we will accomplish this is closing our transaction by using the oncomplete and onerror lifecycle hooks from the transaction we initiate.

export default {
// Other code is excluded for brevity
methods: {
async getTodoStore() {
// Get the most recent update on the database
this.database = await this.getDatabase

return new Promise((resolve, reject) => {
const transaction = db.transaction('todos', 'readonly')
const store = transaction.objectStore('todos')
let todoList = []

store.openCursor().onsuccess = event => {
let cursor = event.target.result
if (cursor) {
todoList.push(cursor.value)
cursor.continue()
}
}

// If the transaction is successful,
// return the data we stored in todoList
transaction.oncomplete = () => {
resolve(todoList)
}

// If the transaction fails,
// return the error event
transaction.onerror = event => {
reject(event)
}
}
}
}
}

Load the object store at the start

Now that we know how to retrieve our object store, the final piece is to ensure that we do this at the start of our app so that any previous data saved can be automatically loaded to the previous state. And the we can accomplish this is by utilizing the created lifecycle hook with an async prefix since we’re fetching data from the database.

export default {
// Other code excluded for brevity
data: () => ({
todos: []
}),
methods: {
async getTodoStore() { ... },
},
async created() {
this.todos = await this.getTodoStore()
}
}

Next Steps

Wow! I know that was a lot of information, but I’m hoping the concepts we learned in the last lesson with getting databases from IndexedDB served as a good foundation for understanding how the code works for object stores.

That said, to review, we’ve learned what object stores are and how to access the data within them through the use of transactions and cursors. In the next lesson, we will leverage the fruits of our labor in this lesson to finally integrate our todo app and start adding data to our newly created object store.