Skip to content
Guides3 min readUpdated

localStorage in React, done properly

The short answer

To use localStorage in React safely you need four things: a lazy useState initialiser so you read storage only once, a typeof window guard so server rendering does not crash, a try/catch around every read and write because storage can be disabled or full, and a storage event listener so other tabs stay in sync. useLocalStorage from @danixsoft/hooks handles all four.

Persisting a preference sounds like a two-line job. The two-line version has four bugs in it, and every one of them shows up in production rather than on your machine.

The version everyone writes first
1function useLocalStorage(key, initial) {
2  // ✗ Reads storage on every render
3  // ✗ Crashes during server rendering
4  // ✗ Throws if the value is not valid JSON
5  // ✗ Other tabs never find out about the change
6  const [value, setValue] = useState(
7    JSON.parse(localStorage.getItem(key)) ?? initial
8  );
9
10  useEffect(() => {
11    localStorage.setItem(key, JSON.stringify(value));
12  }, [key, value]);
13
14  return [value, setValue];
15}

Bug 1: reading storage on every render

useState(expensiveCall()) evaluates its argument on every single render, then throws the result away on all but the first. localStorage.getItem is a synchronous, blocking, main-thread call — doing it 60 times a second during an animation is measurable.

Lazy initialiser: runs exactly once
const [value, setValue] = useState(() => {
  // Only called on the first render.
  return readFromStorage(key, initial);
});

Bug 2: the server has no localStorage

Under Next.js this crashes the build outright. The initialiser must return the fallback when there is no window, which also keeps the server HTML and the first client render in agreement.

const [value, setValue] = useState<T>(() => {
  if (typeof window === 'undefined') return initial;
  // …read storage
});
This deliberately means the first paint shows the default, not the stored value. If a flash is unacceptable — a theme, for instance — you need a blocking inline script as well; see SSR-safe hooks in Next.js.

Bug 3: storage throws more often than you think

Every access can raise. Safari in private browsing has historically thrown on write; browsers with cookies blocked can throw on read; the quota is finite and exceeding it throws; and any hand-edited or half-written value makes JSON.parse throw.

Never let persistence break the app
1const read = (key: string, fallback: T): T => {
2  try {
3    const item = window.localStorage.getItem(key);
4    return item ? (JSON.parse(item) as T) : fallback;
5  } catch (error) {
6    console.warn(`Could not read localStorage key "${key}":`, error);
7    return fallback;
8  }
9};

Key takeaway

Persistence is an enhancement, never a requirement. If storage fails, the component must keep working with in-memory state — a failed write should cost the user their preference, not their session.

Bug 4: two tabs, two truths

Open your app in two tabs, change the theme in one, and the other keeps the old value until it reloads. The browser fires a storage event for exactly this — but only in the other tabs, never the one that made the change.

Cross-tab synchronisation
1useEffect(() => {
2  const onStorage = (event: StorageEvent) => {
3    if (event.key !== key || event.newValue === null) return;
4    try {
5      setValue(JSON.parse(event.newValue));
6    } catch {
7      /* ignore a corrupt write from another tab */
8    }
9  };
10
11  window.addEventListener('storage', onStorage);
12  return () => window.removeEventListener('storage', onStorage);
13}, [key]);

For components inside the same tab to stay in sync, the setter also dispatches a synthetic StorageEvent — otherwise two components using the same key would drift apart. useLocalStorage does this for you.

The finished hook

All four fixes, one import
1import { useLocalStorage } from '@danixsoft/hooks';
2
3interface Preferences {
4  theme: 'light' | 'dark';
5  compact: boolean;
6}
7
8function Settings() {
9  const [prefs, setPrefs] = useLocalStorage<Preferences>('prefs', {
10    theme: 'dark',
11    compact: false,
12  });
13
14  return (
15    <label>
16      <input
17        type="checkbox"
18        checked={prefs.compact}
19        onChange={(e) => setPrefs({ ...prefs, compact: e.target.checked })}
20      />
21      Compact layout
22    </label>
23  );
24}

Which storage should you actually use?

localStoragesessionStorageCookies
LifetimeUntil explicitly clearedUntil the tab closesUntil the expiry you set
ScopeAll tabs on the originOne tabAll tabs, and sent to the server
Capacity~5–10 MB~5 MB~4 KB per cookie
Server can read itNoNoYes, on every request
Good forPreferences, drafts, cachesMulti-step form stateLocale, consent, session ids
HookuseLocalStorageuseSessionStorageuseCookie

Never store credentials

localStorage is readable by any JavaScript running on your origin, which includes anything injected through an XSS vulnerability. Access tokens belong in an httpOnly cookie the browser will not hand to scripts at all.

What not to persist

  • Auth tokens and API keys — use httpOnly cookies.
  • Anything personal you have not disclosed — persisted identifiers can bring the storage under GDPR and similar regimes.
  • Large data sets — the quota is small and the API is synchronous; use IndexedDB past a megabyte or so.
  • Server-derived state — a cached API response goes stale silently and is far more confusing than a refetch.
Why does my localStorage value flash the default on first paint?

Because the server rendered the default and the browser only reads storage after hydration. That is correct and necessary to avoid a hydration mismatch. If the flash is unacceptable for something as visible as a theme, add a blocking inline script in the document head to apply the stored value before the first paint.

How much can I store in localStorage?

Roughly 5 MB per origin in most browsers, though the exact quota varies and is shared with other storage. Exceeding it throws a QuotaExceededError, which is why every write should sit inside a try/catch.

Does useLocalStorage work in React Native?

No. React Native has no localStorage; use AsyncStorage or MMKV instead. The hook works in any browser environment, including React Native Web.

How do I clear a persisted value?

Set it back to the initial value, or call window.localStorage.removeItem(key) directly and update state. Removing the key means the next mount falls back to the initial value you passed.

Keep reading