John Reilly MacGyver turned Dev 🌻❤️ TypeScript / ts-loader / fork-ts-checker-webpack-plugin / DefinitelyTyped: The Movie

Offline storage for PWAs

6 min read 1696

Offline Storage in PWAs

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)?

The problem with 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 needs
  • Window.localStorage cannot be used in the context of a Worker or a ServiceWorker because the APIs are not available there.
  • Window.localStorage stores only strings; while this isn’t a huge problem given JSON.stringify and JSON.parse, it’s certainly an inconvenience

The 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?

Where IndexedDB falls short

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.

IDB-Keyval to the rescue!

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:

  • The API is promise-based; all functions return a Promise, which makes it a nonblocking API
  • Unlike localStorage, the API is not restricted to strings. According to the docs, it’s IDB-backed, meaning you can store anything structured-clonable, such as numbers, arrays, objects, dates, blobs, and more
  • Because this is an abstraction built on top of IndexedDB, it can be used both in the context of a typical web app and also in a Worker or a ServiceWorker if required

Basic use

To 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:

  • Adds a value of 'world' to IndexedDB using IDB-Keyval for the key of 'hello'
  • Queries IndexedDB using IDB-Keyval for the key of 'hello' and stores it in the variable whatDoWeHave
  • Logs 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 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.

Hello World App Using IDB-Keyval

We successfully wrote something into IndexedDB, read it back, and printed that value to the console.

Using IDB-Keyval in React

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.

Dark Mode Option

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
  • The app attempts to load a value from IDB-Keyval with the key 'darkModeOn' and set darkModeOn with the retrieved value. If no value is retrieved, it sets darkModeOn to true
  • When the checkbox is changed, the corresponding value is both applied to 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.

Use Dark Mode With IDB-Keyval

Using IDB-Keyval as a React hook

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.


More great articles from LogRocket:


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;

Conclusion

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.

Get setup with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.
  3. $ 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>
  4. (Optional) Install plugins for deeper integrations with your stack:
    • Redux middleware
    • ngrx middleware
    • Vuex plugin
Get started now
John Reilly MacGyver turned Dev 🌻❤️ TypeScript / ts-loader / fork-ts-checker-webpack-plugin / DefinitelyTyped: The Movie

One Reply to “Offline storage for PWAs”

  1. 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?

Leave a Reply