Blog
Syncing Local Storage with React State

Syncing Local Storage with React State

While creating a solution to sync a React component’s state with localStorage, I found that many existing resources on this topic were either out of date or favoring approaches that are no longer the best option due to recent introductions from the React team.

Let’s explore the commonly suggested solutions, then arrive at the “better” solution that uses useSyncExternalStore.

For our example, we’ll create a simple persistent counter component that stores a count in localStorage.

Pitfall: Setting State in useEffect

Perhaps the most commonly suggested solution is something along the lines of the following:

  1. Have a counter state that is set to a default value.
  2. Use useEffect to set the counter state to the value in localStorage on mount.
  3. Have a second useEffect that updates localStorage whenever the counter state changes.
counter.tsx
import { useState, useEffect } from "react";
 
const STORAGE_KEY = "counter";
 
function Counter() {
  const [counter, setCounter] = useState(0);
 
  useEffect(() => {
    const storedCounter = Number(localStorage.getItem(STORAGE_KEY));
    if (isNaN(storedCounter)) return;
    setCounter(storedCounter);
  }, [setCounter]);
 
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, counter);
  }, [counter]);
 
  return (
    <div>
      <p>{counter}</p>
      <button onClick={() => setCounter((c) => c + 1)}>Increment</button>
    </div>
  );
}

What’s wrong with this? Well, the component will always flash with the default counter value before updating to the value in localStorage, since the useEffect that reads from localStorage runs after the initial render. This also results in an extra write to localStorage when the second effect pickups up the first change to counter.

Fine, so let’s get rid of the flash by getting rid of the effect that sets the initial state. We can instead initialize the state with the value from localStorage:

counter.tsx
import { useState, useEffect } from "react";
 
const STORAGE_KEY = "counter";
 
function getStoredCounter(): number {
  const storedCounter = Number(localStorage.getItem(STORAGE_KEY));
  return isNaN(storedCounter) ? 0 : storedCounter;
}
 
function Counter() {
  const [counter, setCounter] = useState(() => getStoredCounter());
 
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, counter);
  }, [counter]);
 
  return (
    <div>
      <p>{counter}</p>
      <button onClick={() => setCounter((c) => c + 1)}>Increment</button>
    </div>
  );
}

Now the initial flash is gone! We’re done, right? There’s some broader issues with this approach:

  1. State and localStorage can get out of sync: There’s many ways the state of our component (and thus the UI) can get out of sync with localStorage. For example, let’s say the user opens our app in two tabs. If they increment the counter in one tab, the other tab will not reflect the change to local storage. If the user then increments the counter in the second tab, the first tab will now be out of sync with localStorage until they refresh. Moreover, what if another component in the app writes to the same key in localStorage? Even simpler - what if we render two of these counters? In all these cases, the state of one of our component instances will be stale.

  2. UI is a reflection of state: This is a more philosophical point, but a core of React is that UI should ideally be a pure function of state. When our state is contained in an external system, our component is now a function of both state and the external system. This is related to our first point.

The Solution - useSyncExternalStore

At this point, you may encounter more advanced solutions on the Internet that have effects that listen to storage events, etc. However, React 18 introduced a hook that lifts a lot of our burden: useSyncExternalStore.

useSyncExternalStore is a new hook that allows us to subscribe our component’s state with any external store. It takes three options:

  1. subscribe: A function that subscribes to the external store and returns a cleanup function to unsubscribe.
  2. getSnapshot: A function that returns the current value in the external store. This value must be referentially stable - when storing primitives, this is trivial. If you’re planning on storing objects/arrays, you’ll need a mechanism that only returns a new object/array when the underlying value has changed.
  3. getServerSnapshot: Optionally, a function that returns the initial value from the external store. This is useful for server-side rendering. You’ll need this for SSR.

Let’s write a simple hook that uses useSyncExternalStore to have a piece of state that is synced with localStorage:

usePersistentCounter.ts
import { useSyncExternalStore, useState } from "react";
 
const STORAGE_KEY = "counter";
 
function subscribeToCounter(callback: () => void): () => void {
  function listener(event: StorageEvent) {
    if (event.key === STORAGE_KEY) callback();
  }
 
  window.addEventListener("storage", listener);
  return () => window.removeEventListener("storage", listener);
}
 
function getCounter(): number {
  const storedCounter = Number(localStorage.getItem(STORAGE_KEY));
  return isNaN(storedCounter) ? 0 : storedCounter;
}
 
function setCounter(counter: number) {
  const currentValue = getCounter();
  if (currentValue === counter) return;
  localStorage.setItem(STORAGE_KEY, counter.toString());
 
  // Withouth this manual dispatch, the change to localstorage will not
  // be visible to the current tab. It will be automatically visible to
  // other tabs, however.
  window.dispatchEvent(
    new StorageEvent("storage", {
      key: STORAGE_KEY,
      oldValue: currentValue.toString(),
      newValue: counter.toString(),
    })
  );
}
 
export function usePersistentCounter() {
  const counter = useSyncExternalStore(subscribeToCounter, getCounter);
  return [counter, setCounter] as const;
}

Now, our Counter component can be simplified to:

counter.tsx
import { usePersistentCounter } from "./usePersistentCounter";
 
function Counter() {
  const [counter, setCounter] = usePersistentCounter();
 
  return (
    <div>
      <p>{counter}</p>
      <button onClick={() => setCounter((c) => c + 1)}>Increment</button>
    </div>
  );
}

Our subscribe function registers a listener for storage events on our key, which are dispatched any time the key is written to in localStorage. Our getSnapshot function simply reads from localStorage. Our setCounter function writes to localStorage and manually dispatches a storage event to notify the current tab of the change.

We’ve now solved the problem of our state getting out of sync with our external store. In this demo, we can see that our state is synced across instances of the component in the same app, and across instances of the app in different tabs, even when the change is written directly to localStorage.

This is meant to stay as a toy example, but we can easily beef up our abstraction by providing a server snapshot function and adding more configuration options (custom storage key, default value, serializer/deserializer functions, comparators, etc.). We can apply this pattern to other external systems, such as the many other browser APIs that are available.