No matter what type of application you’re building, you may want to store information that persists beyond a single user session. Sometimes (but not always), you want this information to live in some kind of centralized database. You may also want that data to be available if the user is offline so that even if they can’t connect to the network, they can still use the app to perform meaningful tasks.
To drive this capability, the app will likely require a considerable amount of data. How can we achieve this in the context of a progressive web app (PWA)?
localStorage
If you were building a classic web app, you’d probably reach for Window.localStorage
at this point. Window.localStorage
is a longstanding API that stores data beyond a single session. It has a simple API and is very easy to use. However, it presents a few key problems:
Window.localStorage
is synchronous, which is not a tremendous problem for every app, but could lead to issues if you’re building something that has significant performance needsWindow.localStorage
cannot be used in the context of a Worker
or a ServiceWorker
because the APIs are not available there.Window.localStorage
stores only string
s; while this isn’t a huge problem given JSON.stringify
and JSON.parse
, it’s certainly an inconvenienceThe second point here is a significant one. What do you do if you need to access offline data in the context of a ServiceWorker
— which, if you’re working offline, you almost certainly will?
Fortunately, localStorage
is not the only game in town. There’s an alternative offline storage mechanism available in browsers that goes by the curious name of IndexedDB. To quote the docs:
IndexedDB is a transactional database system, like an SQL-based RDBMS. However, unlike SQL-based RDBMSes, which use fixed-column tables, IndexedDB is a JavaScript-based object-oriented database. IndexedDB lets you store and retrieve objects that are indexed with a key; any objects supported by the structured clone algorithm can be stored. You need to specify the database schema, open a connection to your database, and then retrieve and update data within a series of transactions.
It’s clear that IndexedDB is very powerful, but it sure doesn’t sound very simple. A further look at the MDN example of how to interact with IndexedDB does little to contradict that thought.
We’d like to be able to access data offline, but in a simple fashion — much like we could with localStorage
, which has a wonderfully straightforward API. If only someone would build an abstraction on top of IndexedDB to make our lives easier…
Fortunately, someone did.
Jake Archibald of Google created IDB-Keyval, which bills itself as a “super-simple-small promise-based keyval store implemented with IndexedDB.”
The API is essentially equivalent to localStorage
with a few lovely differences:
Promise
, which makes it a nonblocking APIlocalStorage
, the API is not restricted to string
s. According to the docs, it’s IDB-backed, meaning you can store anything structured-clonable, such as numbers, arrays, objects, dates, blobs, and moreWorker
or a ServiceWorker
if requiredTo show how to use IDB-Keyval, we’re going to need an example application. We’ll demonstrate its basic functionality as well as how to use it in an application.
Let’s spin up a TypeScript React app with Create React App:
npx create-react-app offline-storage-in-a-pwa --template typescript
Next, add IDB-Keyval to it.
yarn add idb-keyval
Update the index.tsx
file to add a function that tests using IDB-Keyval.
import React from 'react'; import ReactDOM from 'react-dom'; import { set, get } from 'idb-keyval'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; ReactDOM.render(<App />, document.getElementById('root')); serviceWorker.register(); async function testIDBKeyval() { await set('hello', 'world'); const whatDoWeHave = await get('hello'); console.log(`When we queried idb-keyval for 'hello', we found: ${whatDoWeHave}`); } testIDBKeyval();
The testIDBKeyval
function does the following:
'world'
to IndexedDB using IDB-Keyval for the key of 'hello'
'hello'
and stores it in the variable whatDoWeHave
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 a single asynchronous operation at a time, it’s often valuable to use async/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.
What we’ve done so far is somewhat abstract. To implement a real-world use case, let’s create an application that enables users to choose between a dark mode and the regular display.
To start, we’ll replace our App.tsx
with this:
import React, { useState } from "react"; import "./App.css"; const sharedStyles = { height: "30rem", fontSize: "5rem", textAlign: "center" } as const; function App() { const [darkModeOn, setDarkModeOn] = useState(true) const handleOnChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => setDarkModeOn(target.checked); const styles = { ...sharedStyles, ...(darkModeOn ? { backgroundColor: "black", color: "white" } : { backgroundColor: "white", color: "black" }) }; return ( <div style={styles}> <input type="checkbox" value="darkMode" checked={darkModeOn} id="darkModeOn" name="darkModeOn" style={{ width: "3rem", height: "3rem" }} onChange={handleOnChange} /> <label htmlFor="darkModeOn">Use dark mode?</label> </div> ); } export default App;
When you run the app, you can see how it works.
As you can see, this is implemented using React’s useState
hook. 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 IDB-Keyval
.
We’ll change the code like so:
import React, { useState, useEffect } from "react"; import { set, get } from "idb-keyval"; import "./App.css"; const sharedStyles = { height: "30rem", fontSize: "5rem", textAlign: "center" } as const; function App() { const [darkModeOn, setDarkModeOn] = useState<boolean | undefined>(undefined); useEffect(() => { get<boolean>("darkModeOn").then(value => // If a value is retrieved then use it; otherwise default to true setDarkModeOn(value ?? true) ); }, [setDarkModeOn]); const handleOnChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => { setDarkModeOn(target.checked); set("darkModeOn", target.checked); }; const styles = { ...sharedStyles, ...(darkModeOn ? { backgroundColor: "black", color: "white" } : { backgroundColor: "white", color: "black" }) }; return ( <div style={styles}> {darkModeOn === undefined ? ( <>Loading preferences...</> ) : ( <> <input type="checkbox" value="darkMode" checked={darkModeOn} id="darkModeOn" name="darkModeOn" style={{ width: "3rem", height: "3rem" }} onChange={handleOnChange} /> <label htmlFor="darkModeOn">Use dark mode?</label> </> )} </div> ); } export default App;
Let’s outline the changes.
darkModeOn
is now initialized to undefined
and the app displays a loading message until darkModeOn
has a value'darkModeOn'
and set darkModeOn
with the retrieved value. If no value is retrieved, it sets darkModeOn
to true
darkModeOn
and saved to IDB-Keyval with the key 'darkModeOn'
This means that we are persisting preferences beyond page refresh in a fashion that will work both online and offline.
For bonus points, let’s move this functionality into a reusable React hook.
Create a new usePersistedState.ts
file.
import { useState, useEffect, useCallback } from "react"; import { set, get } from "idb-keyval"; export function usePersistedState<TState>(keyToPersistWith: string, defaultState: TState) { const [state, setState] = useState<TState | undefined>(undefined); useEffect(() => { get<TState>(keyToPersistWith).then(retrievedState => // If a value is retrieved then use it; otherwise default to defaultValue setState(retrievedState ?? defaultState)); }, [keyToPersistWith, setState, defaultState]); const setPersistedValue = useCallback((newValue: TState) => { setState(newValue); set(keyToPersistWith, newValue); }, [keyToPersistWith, setState]); return [state, setPersistedValue] as const; }
This new hook is modeled after the API of useState
and named usePersistentState
. It requires a key, which is the key that will be used to save the data. It also requires a default value to use in case nothing is found during the lookup.
Just like useState
, it returns a stateful value and a function to update it.
Finally, let’s switch over our App.tsx
to use our shiny new hook.
import React from "react"; import "./App.css"; import { usePersistedState } from "./usePersistedState"; const sharedStyles = { height: "30rem", fontSize: "5rem", textAlign: "center" } as const; function App() { const [darkModeOn, setDarkModeOn] = usePersistedState<boolean>("darkModeOn", true); const handleOnChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => setDarkModeOn(target.checked); const styles = { ...sharedStyles, ...(darkModeOn ? { backgroundColor: "black", color: "white" } : { backgroundColor: "white", color: "black" }) }; return ( <div style={styles}> {darkModeOn === undefined ? ( <>Loading preferences...</> ) : ( <> <input type="checkbox" value="darkMode" checked={darkModeOn} id="darkModeOn" name="darkModeOn" style={{ width: "3rem", height: "3rem" }} onChange={handleOnChange} /> <label htmlFor="darkModeOn">Use dark mode?</label> </> )} </div> ); } export default App;
You should now have a solid understanding of how a web application or PWA can safely and easily store data that is persisted between sessions using native browser capabilities.
IndexedDB powered the solution we built in this tutorial. We used IDB-Keyval over IndexedDB for the delightful and familiar abstraction it offers. This allowed us to build a solution with a similarly lovely API.
It’s worth noting that there are alternatives to IDB-Keyval, such as localForage, which would be a particularly good choice if you’re building for older browsers that may lack good IndexedDB support. But be aware that with improved backward compatibility comes larger download size. It’s important to make the tradeoffs that make sense for you.
Lastly, we illustrated how to use IDB-Keyval in a React context. Please note that there’s nothing React-specific about our offline storage mechanism. So if you’re rolling with Vue, Angular, or something else entirely, this tutorial could help you too.
Put simply, offline storage leads to better user experiences. You should consider using it in your applications.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "Offline storage for PWAs"
Why is setState listed as a dependency in the useEffect and useCallback calls in usePersistedState.ts? Since they both call setState, wouldn’t listing setState as a dependency trigger an infinite loop?