Back to React
React
medium
mid

How would you build a custom React hook that syncs state with localStorage across browser tabs?

Mirror useState but read initial value from localStorage and write on change. Handle JSON errors, SSR (no window), quota exceeded, and listen to the storage event for cross-tab sync.

7 min read·~25 min to think through

A persistent-state hook looks like a one-liner over useState, but the production version handles five edge cases that catch out junior implementations: SSR safety, JSON parse errors, quota exceeded, cross-tab sync, and React 18's useSyncExternalStore for tear-free reads.

Naive version:

tsx
function usePersistentState<T>(key: string, initial: T) {
  const [v, setV] = useState<T>(() => {
    const raw = localStorage.getItem(key);
    return raw ? JSON.parse(raw) : initial;
  });
  useEffect(() => { localStorage.setItem(key, JSON.stringify(v)); }, [key, v]);
  return [v, setV] as const;
}

This breaks under SSR (no localStorage), parse errors (corrupt entries), full quota, and doesn't sync between tabs.

Production version:

tsx
function usePersistentState<T>(key: string, initial: T) {
  const read = useCallback((): T => {
    if (typeof window === "undefined") return initial; // SSR
    try {
      const raw = window.localStorage.getItem(key);
      return raw === null ? initial : (JSON.parse(raw) as T);
    } catch {
      return initial;
    }
  }, [key, initial]);

  const [value, setValue] = useState<T>(read);

  // Persist on change
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // QuotaExceededError or serialization error — drop silently or surface a toast
    }
  }, [key, value]);

  // Cross-tab sync
  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === key && e.storageArea === window.localStorage) {
        try {
          setValue(e.newValue === null ? initial : (JSON.parse(e.newValue) as T));
        } catch {
          /* ignore corrupt write from another tab */
        }
      }
    };
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, [key, initial]);

  return [value, setValue] as const;
}

Key decisions explained.

  • Lazy initial state. Pass read (function) to useState so localStorage reads only happen on mount, not every render.
  • SSR. Guard typeof window so server render returns the fallback. Hydration then re-runs the read on client.
  • JSON errors. Wrap parse in try/catch; corrupt entries shouldn't crash the app.
  • Quota. setItem can throw QuotaExceededError. Decide: silently drop (good for caches), or notify (good for user data).
  • Cross-tab. The storage event fires in other tabs when localStorage changes — perfect for "logout in tab A logs out tab B" or "preference change syncs."

React 18 upgrade: useSyncExternalStore. For tear-free concurrent rendering, the canonical implementation uses useSyncExternalStore so reads are consistent across a render. Most apps don't need this, but it's the spec-correct version.

tsx
function usePersistentState<T>(key: string, initial: T) {
  const subscribe = useCallback((cb: () => void) => {
    const handler = (e: StorageEvent) => { if (e.key === key) cb(); };
    window.addEventListener("storage", handler);
    return () => window.removeEventListener("storage", handler);
  }, [key]);
  const getSnapshot = () => window.localStorage.getItem(key);
  const getServerSnapshot = () => null;
  const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const value = useMemo<T>(() => { try { return raw ? JSON.parse(raw) : initial; } catch { return initial; } }, [raw, initial]);
  const setValue = useCallback((next: T | ((prev: T) => T)) => {
    const updated = typeof next === "function" ? (next as any)(value) : next;
    window.localStorage.setItem(key, JSON.stringify(updated));
    // Manually trigger same-tab listeners (storage event doesn't fire in writing tab)
    window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(updated) }));
  }, [key, value]);
  return [value, setValue] as const;
}

Same-tab caveat. The storage event does NOT fire in the tab that did the write. If multiple components in the same tab use the same key, you need a custom event (as above) or a shared in-memory store.

Code

tsx
function ThemeToggle() {
  const [theme, setTheme] = usePersistentState<"light" | "dark">("theme", "light");
  return <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>{theme}</button>;
}
Usage

Follow-up questions

  • Why does the storage event not fire in the writing tab?
  • How would you adapt this for sessionStorage?
  • How does useSyncExternalStore prevent tearing?
  • How would you support a TTL / expiry per key?

Common mistakes

  • Reading localStorage at render time (not lazy) — perf hit and SSR crash.
  • No try/catch on JSON.parse — one corrupt key crashes the app.
  • Forgetting cross-tab sync — preferences diverge between tabs.
  • Letting QuotaExceededError bubble — kills the app on full storage.

Performance considerations

  • JSON serialize on every change — fine for small data, costly for large objects. Throttle if needed.
  • Many components reading the same key independently each parse JSON; share via context if hot.

Edge cases

  • Quota full → setItem throws; fall back to in-memory or evict.
  • Private mode in some browsers gives 0 quota.
  • Key removed in another tab → e.newValue is null; restore initial.

Real-world examples

  • Theme toggles, sidebar collapsed state, draft autosaves, recent-search history, opt-in flags.

Senior engineer discussion

Senior signal: SSR guard, lazy init, cross-tab via storage event + same-tab gap, and awareness of useSyncExternalStore for concurrent-safe reads.

Related questions