Back to React
React
medium
mid

How would you create a React hook that syncs component state with localStorage or sessionStorage?

`useStorage(key, initial, storage)`: lazy init from storage (parse JSON, fallback to initial); useState pair; useEffect writes to storage when value changes; listen to `storage` event to sync across tabs; handle SSR (no `window` on server) by skipping storage on first render; handle parse errors and quota.

5 min read·~25 min to think through

Useful, classic custom hook. Looks small; has subtle correctness corners — SSR, cross-tab sync, parse errors, quota.

The hook

js
import { useState, useEffect, useCallback } from "react";

export function useStorage(key, initialValue, storageType = "local") {
  const storage = typeof window !== "undefined"
    ? (storageType === "local" ? localStorage : sessionStorage)
    : null;

  // Lazy init: read once on mount
  const [value, setValue] = useState(() => {
    if (!storage) return initialValue;                  // SSR
    try {
      const raw = storage.getItem(key);
      return raw != null ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Write to storage when value changes
  useEffect(() => {
    if (!storage) return;
    try {
      if (value === undefined) storage.removeItem(key);
      else storage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.warn(`useStorage: failed to set ${key}`, err);   // quota / privacy mode
    }
  }, [key, value, storage]);

  // Cross-tab sync
  useEffect(() => {
    if (!storage || storageType !== "local") return;
    const onStorage = (e) => {
      if (e.key === key && e.storageArea === storage) {
        try {
          setValue(e.newValue != null ? JSON.parse(e.newValue) : initialValue);
        } catch {/* ignore */}
      }
    };
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, [key, storage, storageType]);

  const remove = useCallback(() => setValue(undefined), []);
  return [value, setValue, remove];
}

Usage

jsx
const [theme, setTheme] = useStorage("theme", "light");
const [draft, setDraft] = useStorage("draft", { title: "" }, "session");

Why each piece

Lazy init

The function form of useState runs once. Reading from localStorage synchronously is fine for small values; lazy init avoids running it on every render.

SSR-safe

On the server, window is undefined → storage is null. We return initialValue without reading or writing. Hydration mismatch is the risk here — see the section below.

Effect-driven write

Write in a useEffect so it doesn't run during render. Catches sync (quota exceeded, private mode in older browsers).

Cross-tab sync

The storage event fires in other tabs when a tab sets/removes a value (not in the writing tab itself). Listen to keep tabs in sync. sessionStorage is per-tab so this doesn't apply.

SSR / hydration concern

The first client render returns initialValue (because the server can't read storage), then the effect (or a subsequent render) updates to the stored value — causing a flash.

Mitigations:

  • useSyncExternalStore for hydration-correct external state (React 18).
  • Render a placeholder until mounted:
jsx
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;  // or skeleton
  • For theme specifically, set the class on <html> server-side (cookie) or in a small inline <script> before React paints.

useSyncExternalStore version (hydration-correct)

js
import { useSyncExternalStore } from "react";

export function useStorageSync(key, initialValue) {
  const subscribe = (callback) => {
    window.addEventListener("storage", callback);
    return () => window.removeEventListener("storage", callback);
  };
  const getSnapshot = () => {
    const raw = localStorage.getItem(key);
    return raw != null ? raw : JSON.stringify(initialValue);
  };
  const getServerSnapshot = () => JSON.stringify(initialValue);

  const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const value = JSON.parse(stored);

  const setValue = (next) => {
    const v = typeof next === "function" ? next(value) : next;
    localStorage.setItem(key, JSON.stringify(v));
    // dispatch a synthetic storage event for same-tab listeners (built-in storage event doesn't fire in same tab)
    window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(v) }));
  };

  return [value, setValue];
}

Edge cases

  • JSON parse failure — fall back to initial.
  • Quota exceeded — warn, don't crash.
  • storage event doesn't fire in same tab — manually dispatch if multiple parts of the same tab use the hook.
  • Functional setter with useState works for free (setValue((v) => ...)).
  • Key changes — the hook re-reads on key change; intended behavior.
  • Schema migrations — when shapes evolve, version your keys (theme.v2).

Don't use for

  • Sensitive data — XSS-readable.
  • Large data — IndexedDB instead.
  • Cross-domain — storage is origin-scoped.

Interview framing

"useStorage(key, initial) returns [value, setValue]. Lazy-init from storage on mount (JSON parse, fallback on error); write to storage in a useEffect on value change. Listen to the storage event to sync across tabs. SSR-safe by skipping storage when window is undefined and using initialValue — but that causes a hydration flash; for hydration-correct behavior use useSyncExternalStore with a server snapshot or render a placeholder until mounted. Handle parse failures, quota errors (especially Safari private mode), and dispatch a synthetic storage event for same-tab subscribers if you need them. Don't store secrets — XSS-readable."

Follow-up questions

  • Why does the storage event not fire in the same tab?
  • How do you avoid the hydration flash for theme?
  • What does useSyncExternalStore give you here?
  • How do you handle storage schema changes over time?

Common mistakes

  • Reading from storage on every render (not lazy init).
  • Writing in render instead of effect.
  • Forgetting cross-tab storage event listener.
  • Crashing on quota / parse errors.
  • Hydration mismatch from server returning initial.

Performance considerations

  • Storage reads are sync (small ok, large blocks). Debounce writes for very hot state.

Edge cases

  • Safari private mode quota = 0.
  • Storage event not firing in same tab.
  • Same-key writes in fast succession from different components.

Real-world examples

  • Theme preference, last-used filter, draft form data.
  • useHooks-ts useLocalStorage, usehooks-ts library.

Senior engineer discussion

Seniors handle SSR + hydration properly (placeholder or useSyncExternalStore), set up cross-tab sync via the storage event, dispatch synthetic events for same-tab needs, and gracefully handle quota/parse errors.

Related questions