DEV Community

loading...
Cover image for How to store data client-side with IndexedDB

How to store data client-side with IndexedDB

shaileshcodes profile image Shailesh Vasandani Originally published at shaile.sh ・9 min read

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, and IndexedDB. 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, 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).

     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
        }
      }
Enter fullscreen mode Exit fullscreen mode

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:

      read(key) {
        return new Promise((resolve, reject) => {
          // TODO
        });
      }
Enter fullscreen mode Exit fullscreen mode

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:

      db = new DB();

      db.read('testKey')
        .then(value => { console.log(value) })
        .catch(err => { console.error(err) });` 
Enter fullscreen mode Exit fullscreen mode

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:

      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
          };
        });
      }
Enter fullscreen mode Exit fullscreen mode

If the open errors out, we can simply reject it with a useful error message:

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

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:

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

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:

      let database = e.target.result;
Enter fullscreen mode Exit fullscreen mode

Now that we have the database, we can get the transaction and the store consecutively.

      let transaction = database.transaction([ _config.storeName ]);
      let objectStore = transaction.objectStore(_config.storeName);
Enter fullscreen mode Exit fullscreen mode

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.

      let objectRequest = objectStore.get(key);
Enter fullscreen mode Exit fullscreen mode

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:

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

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

And with that done, here's our read function in its entirety:

      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."));
            };
          };
        });
      }
Enter fullscreen mode Exit fullscreen mode
Deleting from the database

The delete function goes through a lot of the same steps. Here's the whole function:

      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.");
            };
          };
        });
      }
Enter fullscreen mode Exit fullscreen mode

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:

      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.");
            };
          };
        });
      }
Enter fullscreen mode Exit fullscreen mode

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:

      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.");
              };
            };
          });
        }
      }
Enter fullscreen mode Exit fullscreen mode

To use it, all you'd have to do is create a new DB object and call the specified methods. For example:

      const db = new DB();

      db.save('testKey', 12)
        .then(() => {
          db.get('testKey').then(console.log); // -> prints "12"
        })
Enter fullscreen mode Exit fullscreen mode

Some finishing touches

If you want to use it in another file, just add an export statement to the end:

      export default DB;
Enter fullscreen mode Exit fullscreen mode

Then, import it in the new script (making sure everything supports modules), and call it:

      import DB from './db';
Enter fullscreen mode Exit fullscreen mode

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 and Medium, and your support on either platform would be very much appreciated. I also have a membership 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. Until next time!

Discussion (0)

Forem Open with the Forem app