How to store data client-side with IndexedDB
Tags: javascript, webdev, codenewbie, tutorial
Published: 2021-01-22 03:01:00

Imagine a calculus exam where you had to do all the calculations in your head. It's _technically_ possible, but there's absolutely no reason to do it. The same principle applies to storing things in the browser.

Today, there are a number of widely implemented technologies for client-side storage. We have cookies, the [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). While it's entirely possible to write a fully functioning web application without worrying about any of these, you shouldn't. So how do you use them? Well each of them has a use case that they're best suited to.

#### A quick overview of browser storage

##### Cookies

Cookies, being sent on basically every request, are best used for short bits of data. The big advantage of cookies is that servers can set them directly by using the `Set-Cookie` header, no JavaScript required. On any subsequent requests, the client will then send a `Cookie` header with all previously set cookies. The downside of this is that large cookies can seriously slow down requests. That's where the next two technologies come in.

##### Web Storage

The Web Storage API is composed of two similar stores — `localStorage` and `sessionStorage`. They both have the same interface, but the latter lasts only while the browsing session is active. The former persists as long as there is available memory. This memory limit is both its largest advantage and disadvantage.

Because these values aren't sent along with every request, it's possible to store large amounts of data in them without affecting performance. However, "large" is relative, and the storage limit can vary wildly across browsers. A good rule of thumb is to store no more than 5 MB for your _entire_ site. That limit isn't ideal, and if you need to store more than that, you're probably going to need the third and final API.

##### IndexedDB

IndexedDB, one might argue, is criminally underrated. Despite being supported across [basically every browser](https://caniuse.com/indexeddb), it's nowhere near as popular as the other two. It's not sent with every request like cookies are, and it doesn't have the arbitrary limits of Web Storage. So what gives?

The reason IndexedDB is not very popular is, it turns out, that it's an absolute pain to use. Instead of using `Promises` or `async/await`, you need to define success and error handlers manually. Many libraries encapsulate this functionality, but they can often be overkill. If all you need is to save and load data, you can write everything you need yourself.

#### Wrapping IndexedDB neatly

While there are lots of ways to interface with IndexedDB, what I'll be describing is my personal, **opinionated** way of doing so. This code works for one database and one table, but should be easily modified to fit other use cases. Before we jump into code, let's make a quick list of what requirements we need.

**1\. Ideally, it's some sort of class or object that we can import and export.**

**2\. Each "object" should represent one** `database` **and** `table` **only.**

**3\. Much like a CRUD API, we need methods to read, save, and delete key-value pairs.**

That seems simple enough. Just a side note - we'll be using ES6 `class` syntax here, but you can modify that as you wish. You don't even need to use a class if you're only using it for one file. Now let's get started.

##### Some boilerplate

We know essentially what methods we need, so we can stub those out and make sure all the functions make sense. That way, it's easier to code and test (which I didn't do because it was for a personal project, but I really should get onto that).

Hey, it looks like you're on a slightly narrower screen. The code blocks below might not look too good, but the rest of the article should be fine. You can hop on a wider screen if you want to follow along. I'm not going anywhere (promise).
```javascript
     class DB {
        constructor(dbName="testDb", storeName="testStore", version=1) {
          this._config = {
            dbName,
            storeName,
            version
          };
        }

        set _config(obj) {
          console.error("Only one config per DB please");
        }

        read(key) {
          // TODO
        }

        delete(key) {
          // TODO
        }

        save(key, value) {
          // TODO
        }
      }
```
Here we've set up some boilerplate that has all of our functions, and a nice constant configuration. The `setter` around `_config` ensures that the configuration can't be changed at any point. That will help both debug any errors and prevent them from happening in the first place.

With the boilerplate all done, it's time to move on to the interesting part. Let's see what we can do with IndexedDB.

##### Reading from the database

Even though IndexedDB doesn't use `Promises`, we'll be wrapping all of our functions in them so that we can work asynchronously. In a sense, the code we'll be writing will help bridge the gap between IndexedDB and more modern ways of writing JavaScript. In our `read` function, let's wrap everything in a new `Promise`:
```javascript
      read(key) {
        return new Promise((resolve, reject) => {
          // TODO
        });
      }
```
If and when we get the value from the database, we'll use the `resolve` argument to pass it along the `Promise` chain. That means we can do something like this somewhere else in the code:
```javascript
      db = new DB();

      db.read('testKey')
        .then(value => { console.log(value) })
        .catch(err => { console.error(err) });` 
```
Now that we have that set up, let's look at what we need to do to open up the connection. To open the actual database, all we need to do is call the `open` method of the `window.indexedDB` object. We're also going to need to handle three different cases — if there's an error, if the operation succeeds, and if we need an upgrade. We'll stub those out for now. What we have so far looks like this:
```javascript
      read(key) {
        return new Promise((resolve, reject) => {
          let dbRequest = window.indexedDB.open(dbConfig.dbName);

          dbRequest.onerror = (e) => {
            // TODO
          };

          dbRequest.onupgradeneeded = (e) => {
            // TODO
          };

          dbRequest.onsuccess = (e) => {
            // TODO
          };
        });
      }
```
If the `open` errors out, we can simply `reject` it with a useful error message:
```javascript
      dbRequest.onerror = (e) => {
        reject(Error("Couldn't open database."));
      };
```
For the second handler, `onupgradeneeded`, we don't need to do much. This handler is only called when the `version` we provide in the constructor doesn't already exist. If the version of the database doesn't exist, there's nothing to read from. Thus, all we have to do is abort the transaction and reject the `Promise`:
```javascript
      dbRequest.onupgradeneeded = (e) => {
        e.target.transaction.abort();
        reject(Error("Database version not found."));
      };
```
That leaves us with the third and final handler, for the success state. This is where we'll be doing the actual reading. I glossed over the transaction in the previous handler, but it's worth spending the time to go over now. Because IndexedDB is a NoSQL database, reads and writes are performed in **transactions**. These are just records of the different operations being performed on the database, and can be reverted or reordered in different ways. When we aborted the transaction above, all we did was tell the computer to cancel any pending changes.

Now that we have the database though, we'll need to do more with our transaction. First, let's get the actual database:
```javascript
      let database = e.target.result;
```
Now that we have the database, we can get the transaction and the store consecutively.
```javascript
      let transaction = database.transaction([ _config.storeName ]);
      let objectStore = transaction.objectStore(_config.storeName);
```
The first line creates a new transaction and declares its _scope_. That is, it tells the database that it'll only be working with one store, or table. The second gets the store and assigns it to a variable.

With that variable, we can finally do what we set out to. We can call the `get` method of that store to get the value associated with the key.
```javascript
      let objectRequest = objectStore.get(key);
```
We're just about done here. All that's left to do is to take care of the error and success handlers. One important thing to note is that we're checking to see if the actual result exists. If it doesn't we'll throw an error as well:
```javascript
      objectRequest.onerror = (e) => {
        reject(Error("Error while getting."));
      };

      objectRequest.onsuccess = (e) => {
        if (objectRequest.result) {
          resolve(objectRequest.result);
        } else reject(Error("Key not found."));
      };
```
And with that done, here's our `read` function in its entirety:
```javascript
      read(key) {
        return new Promise((resolve, reject) => {
          let dbRequest = window.indexedDB.open(_config.dbName);

          dbRequest.onerror = (e) => {
            reject(Error("Couldn't open database."));
          };

          dbRequest.onupgradeneeded = (e) => {
            e.target.transaction.abort();
            reject(Error("Database version not found."));
          };

          dbRequest.onsuccess = (e) => {
            let database = e.target.result;
            let transaction = database.transaction([ _config.storeName ], 'readwrite');
            let objectStore = transaction.objectStore(_config.storeName);
            let objectRequest = objectStore.get(key);

            objectRequest.onerror = (e) => {
              reject(Error("Error while getting."));
            };

            objectRequest.onsuccess = (e) => {
              if (objectRequest.result) {
                resolve(objectRequest.result);
              } else reject(Error("Key not found."));
            };
          };
        });
      }
```
##### Deleting from the database

The `delete` function goes through a lot of the same steps. Here's the whole function:
```javascript
      delete(key) {
        return new Promise((resolve, reject) => {
          let dbRequest = indexedDB.open(_config.dbName);

          dbRequest.onerror = (e) => {
            reject(Error("Couldn't open database."));
          };

          dbRequest.onupgradeneeded = (e) => {
            e.target.transaction.abort();
            reject(Error("Database version not found."));
          };

          dbRequest.onsuccess = (e) => {
            let database = e.target.result;
            let transaction = database.transaction([ _config.storeName ], 'readwrite');
            let objectStore = transaction.objectStore(_config.storeName);
            let objectRequest = objectStore.delete(key);

            objectRequest.onerror = (e) => {
              reject(Error("Couldn't delete key."));
            };

            objectRequest.onsuccess = (e) => {
              resolve("Deleted key successfully.");
            };
          };
        });
      }
```
You'll notice two differences here. First, we're calling `delete` on the `objectStore`. Second, the success handler resolves right away. Other than those two, the code is essentially identical. This is the same for the third and final function.

##### Saving to the database

Again, because it's so similar, here's the entirety of the `save` function:
```javascript
      save(key, value) {
        return new Promise((resolve, reject) => {
          let dbRequest = indexedDB.open(dbConfig.dbName);

          dbRequest.onerror = (e) => {
            reject(Error("Couldn't open database."));
          };

          dbRequest.onupgradeneeded = (e) => {
            let database = e.target.result;
            let objectStore = database.createObjectStore(_config.storeName);
          };

          dbRequest.onsuccess = (e) => {
            let database = e.target.result;
            let transaction = database.transaction([ _config.storeName ], 'readwrite');
            let objectStore = transaction.objectStore(_config.storeName);
            let objectRequest = objectStore.put(value, key); // Overwrite if exists

            objectRequest.onerror = (e) => {
              reject(Error("Error while saving."));
            };

            objectRequest.onsuccess = (e) => {
              resolve("Saved data successfully.");
            };
          };
        });
      }
```
There are three differences here. The first is that the `onupgradeneeded` handler needs to be filled in. That makes sense, since setting values in a new version of the database should be supported. In it, we simply create the `objectStore` using the aptly named `createObjectStore` method. The second difference is that we're using the `put` method of the `objectStore` to save the value instead of reading or deleting it. The final difference is that, like the `delete` method, the success handler resolves immediately.

With all that done, here's what it looks like all put together:
```javascript
      class DB {
        constructor(dbName="testDb", storeName="testStore", version=1) {
          this._config = {
            dbName,
            storeName,
            version
          };
        }

        set _config(obj) {
          console.error("Only one config per DB please");
        }

        read(key) {
          return new Promise((resolve, reject) => {
            let dbRequest = window.indexedDB.open(_config.dbName);

            dbRequest.onerror = (e) => {
              reject(Error("Couldn't open database."));
            };

            dbRequest.onupgradeneeded = (e) => {
              e.target.transaction.abort();
              reject(Error("Database version not found."));
            };

            dbRequest.onsuccess = (e) => {
              let database = e.target.result;
              let transaction = database.transaction([ _config.storeName ], 'readwrite');
              let objectStore = transaction.objectStore(_config.storeName);
              let objectRequest = objectStore.get(key);

              objectRequest.onerror = (e) => {
                reject(Error("Error while getting."));
              };

              objectRequest.onsuccess = (e) => {
                if (objectRequest.result) {
                  resolve(objectRequest.result);
                } else reject(Error("Key not found."));
              };
            };
          });
        }

        delete(key) {
          return new Promise((resolve, reject) => {
            let dbRequest = indexedDB.open(_config.dbName);

            dbRequest.onerror = (e) => {
              reject(Error("Couldn't open database."));
            };

            dbRequest.onupgradeneeded = (e) => {
              e.target.transaction.abort();
              reject(Error("Database version not found."));
            };

            dbRequest.onsuccess = (e) => {
              let database = e.target.result;
              let transaction = database.transaction([ _config.storeName ], 'readwrite');
              let objectStore = transaction.objectStore(_config.storeName);
              let objectRequest = objectStore.delete(key);

              objectRequest.onerror = (e) => {
                reject(Error("Couldn't delete key."));
              };

              objectRequest.onsuccess = (e) => {
                resolve("Deleted key successfully.");
              };
            };
          });
        }

        save(key, value) {
          return new Promise((resolve, reject) => {
            let dbRequest = indexedDB.open(dbConfig.dbName);

            dbRequest.onerror = (e) => {
              reject(Error("Couldn't open database."));
            };

            dbRequest.onupgradeneeded = (e) => {
              let database = e.target.result;
              let objectStore = database.createObjectStore(_config.storeName);
            };

            dbRequest.onsuccess = (e) => {
              let database = e.target.result;
              let transaction = database.transaction([ _config.storeName ], 'readwrite');
              let objectStore = transaction.objectStore(_config.storeName);
              let objectRequest = objectStore.put(value, key); // Overwrite if exists

              objectRequest.onerror = (e) => {
                reject(Error("Error while saving."));
              };

              objectRequest.onsuccess = (e) => {
                resolve("Saved data successfully.");
              };
            };
          });
        }
      }
```
To use it, all you'd have to do is create a new `DB` object and call the specified methods. For example:
```javascript
      const db = new DB();

      db.save('testKey', 12)
        .then(() => {
          db.get('testKey').then(console.log); // -> prints "12"
        })
```
#### Some finishing touches

If you want to use it in another file, just add an export statement to the end:
```javascript
      export default DB;
```
Then, import it in the new script (making sure everything supports modules), and call it:
```javascript
      import DB from './db';
```
Then, use it as is.

As always, don't forget to follow me for more content like this. I'm currently writing on [dev.to](https://shaile.sh/devto) and [Medium](https://shaile.sh/medium), and your support on either platform would be very much appreciated. I also have a [membership](https://www.buymeacoffee.com/shailesh/posts) set up, where you can get early previews of articles and exclusive access to a whole bunch of resources. Also, if you've particularly enjoyed this post, consider supporting me by [buying me a coffee](https://shaile.sh/coffee). Until next time!