When you are building any kind of application it's typical to want to store information which persists beyond a single user session. Sometimes that will be information that you'll want to live in some kind of centralised database, but not always.
Also, you may want that data to still be available if your user is offline. Even if they can't connect to the network, the user may still be able to use the app to do meaningful tasks; but the app will likely require a certain amount of data to drive that.
How can we achieve this in the context of a PWA?
The problem with
If you were building a classic web app you'd probably be reaching for
Window.localStorage at this point.
Window.localStorage is a long existing API that stores data beyond a single session. It has a simple API and is very easy to use. However, it has a couple of problems:
Window.localStorageis synchronous. Not a tremendous problem for every app, but if you're building something that has significant performance needs then this could become an issue.
Window.localStoragecannot be used in the context of a
ServiceWorker. The APIs are not available there.
JSON.parsethat's not a big problem. But it's an inconvenience.
The second point here is the significant one. If we've a need to access our offline data in the context of a
ServiceWorker (and if you're offline you'll be using a
ServiceWorker) then what do you do?
localStorage is not the only game in town. There's alternative offline storage mechanism available in browsers with the curious name of IndexedDB. To quote the docs:
It's clear that IndexedDB is very powerful. But it doesn't sound very simple. A further look at the MDN example of how to interact with IndexedDB does little to remove that thought.
We'd like to be able to access data offline; but in a simple fashion. Like we could with
localStorage which has a wonderfully straightforward API. If only someone would build an astraction on top of IndexedDB to make our lives easier...
A super-simple-small promise-based keyval store implemented with IndexedDB
The API is essentially equivalent to
localStorage with a few lovely differences:
- The API is promise based; all functions return a
Promise; this makes it a non-blocking API.
- The API is not restricted to
localStorageis. To quote the docs: this is IDB-backed, you can store anything structured-clonable (numbers, arrays, objects, dates, blobs etc)
- Because this is abstraction built on top of IndexedDB, it can be used both in the context of a typical web app and also in a
Let's take a look at what usage of
IDB-Keyval might be like. For that we're going to need an application. It would be good to be able to demonstrate both simple usage and also how usage in the context of an application might look.
Let's spin up a TypeScript React app with Create React App:
This creates us a simple app. Now let's add IDB-Keyval to it:
Then, let's update the
index.tsx file to add a function that tests using IDB-Keyval:
As you can see, we've added a
testIDBKeyval function which does the following:
- Adds a value of
'world'to IndexedDB using IDB-Keyval for the key of
- Queries IndexedDB using IDB-Keyval for the key of
'hello'and stores it in the variable
- Logs out what we found.
You'll also note that
testIDBKeyval is an
async function. This is so that we can use
await when we're interacting with IDB-Keyval. Given that its API is
Promise based, it is
await friendly. Where you're performing more than an a single asynchronous operation at a time, it's often valuable to use
await to increase the readability of your codebase.
What happens when we run our application with
yarn start? Let's do that and take a look at the devtools:
We successfully wrote something into IndexedDB, read it back and printed that value to the console. Amazing!
What we've done so far is slightly abstract. It would be good to implement a real-world use case. Let's create an application which gives users the choice between using a "Dark mode" version of the app or not. To do that we'll replace our
App.tsx with this:
When you run the app you can see how it works:
Looking at the code you'll be able to see that this is implemented using React's
useState hook. So any user preference selected will be lost on a page refresh. Let's see if we can take this state and move it into IndexedDB using
We'll change the code like so:
The changes here are:
darkModeOnis now initialised to
undefinedand the app displays a loading message until
darkModeOnhas a value.
- The app attempts to app load a value from IDB-Keyval with the key
darkModeOnwith the retrieved value. If no value is retrieved then it sets
- When the checkbox is changed, the corresponding value is both applied to
darkModeOnand saved to IDB-Keyval with the key
As you can see, this means that we are persisting preferences beyond page refresh in a fashion that will work both online and offline!
Finally it's time for bonus points. Wouldn't it be nice if we could move this functionality into a reusable React hook? Let's do it!
Let's create a new
This new hook is modelled after the API of
useState and is named
usePersistentState. It requires that a key be supplied which is the key that will be used to save the data. It also requires a default value to use in the case that nothing is found during the lookup.
It returns (just like
useState) a stateful value, and a function to update it. Finally, let's switch over our
App.tsx to use our shiny new hook:
This post has demonstrate how a web application or a PWA can safely store data that is persisted between sessions using native browser capabilities easily. IndexedDB powered the solution we've built. We used used IDB-Keyval for the delightful and familiar abstraction it offers over IndexedDB. It's allowed us to come up with a solution with a similarly lovely API. It's worth knowing that there are alternatives to IDB-Keyval available such as localForage. If you are building for older browsers which may lack good IndexedDB support then this would be a good choice. But be aware that with greater backwards compatibility comes greater download size. Do consider this and make the tradeoffs that make sense for you.
Finally, I've finished this post illustrating what usage would look like in a React context. Do be aware that there's nothing React specific about our offline storage mechanism. So if you're rolling with Vue, Angular or something else entirely: this is for you too! Offline storage is a feature that provide much greater user experiences. Please do consider making use of it in your applications.