Back to React
React
medium
mid

How would you handle hydration and edge cases like invalid JSON or storage limits in a localStorage hook?

Hydrate persisted state in a useEffect (not at render) to avoid SSR mismatches. Wrap JSON.parse in try/catch; treat parse failure as 'no saved state'. Catch QuotaExceededError on writes and degrade (clear oldest, warn user, switch to in-memory). Validate persisted shape with a schema (Zod) and migrate or discard on version mismatch.

4 min read·~12 min to think through

Persisting state (theme, draft, cart) sounds simple. The edge cases bite:

  • SSR hydration mismatch if initial render reads storage (server didn't have it).
  • JSON.parse throws on corrupt/legacy data.
  • localStorage quota ~5MB; writes throw QuotaExceededError.
  • Storage disabled in private browsing or by user prefs.
  • Schema drift across releases.

Safe load

ts
function safeLoad<T>(key: string, schema: ZodSchema<T>, fallback: T): T {
  try {
    const raw = localStorage.getItem(key);
    if (raw === null) return fallback;
    const parsed = JSON.parse(raw);
    const result = schema.safeParse(parsed);
    return result.success ? result.data : fallback;
  } catch {
    return fallback;
  }
}

Try/catch wraps both JSON.parse and storage access (storage can throw if disabled).

Safe save

ts
function safeSave(key: string, value: unknown): boolean {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch (e: any) {
    if (e?.name === "QuotaExceededError" || e?.code === 22) {
      // Could evict older keys, downgrade to in-memory, or warn user
      console.warn("Storage quota exceeded; data not persisted");
    }
    return false;
  }
}

Hydration shape for SSR + client

tsx
function useStoredState<T>(key: string, schema: ZodSchema<T>, fallback: T) {
  const [state, setState] = useState<T>(fallback);   // server + first client render

  useEffect(() => {
    setState(safeLoad(key, schema, fallback));        // load only on client
  }, [key]);

  useEffect(() => {
    safeSave(key, state);
  }, [key, state]);

  return [state, setState] as const;
}

The initial render uses fallback so server and client match; the effect upgrades the value on mount. Some flicker is acceptable for prefs; for theme/dark mode, set a class on <html> via inline <script> before React boots to avoid flash.

Schema migration

Version the stored shape:

ts
const Stored = z.object({
  __v: z.literal(2),
  cart: z.array(CartItem),
});

function migrate(old: any): z.infer<typeof Stored> | null {
  if (old?.__v === 1) {
    return { __v: 2, cart: old.items?.map(transformOld) ?? [] };
  }
  if (old?.__v === 2) return old;
  return null;   // unrecognized; discard
}

Zustand persist gotchas

zustand/persist covers many of these but you still need:

  • onRehydrateStorage to react to hydration.
  • migrate for schema drift.
  • version bump on each breaking shape change.
  • Be careful with SSR — gate state until hydration.

Theme/dark-mode without flash

html
<!-- in <head>, before React boots -->
<script>
  (function () {
    var t = localStorage.getItem("theme") || "system";
    var dark = t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
    document.documentElement.classList.toggle("dark", dark);
  })();
</script>

Synchronous; runs before paint; no flash. React picks up the class on mount.

Storage events for cross-tab sync

ts
useEffect(() => {
  const onStorage = (e: StorageEvent) => {
    if (e.key === KEY) setState(safeLoad(KEY, schema, fallback));
  };
  window.addEventListener("storage", onStorage);
  return () => window.removeEventListener("storage", onStorage);
}, []);

Tabs stay in sync without polling.

When to use IndexedDB instead

  • > 5MB.
  • Binary data.
  • Async API (doesn't block main thread).
  • Use idb-keyval for an API as simple as localStorage.

Interview framing

"Initial render uses the fallback so SSR and first client render match; an effect upgrades from storage afterward. safeLoad wraps storage access + JSON.parse + schema validation (Zod) in try/catch — corruption or legacy shape becomes 'no saved state'. safeSave catches QuotaExceededError and degrades (warn user, evict, switch to in-memory). Version the schema and migrate on read. For theme/dark-mode, set the class via a synchronous inline script before React boots to avoid flash. Listen to the storage event for cross-tab sync. For > 5MB go IndexedDB."

Follow-up questions

  • Walk through theme persistence without flash.
  • How would you migrate a stored shape?
  • What does QuotaExceededError look like?

Common mistakes

  • Reading localStorage during render → hydration mismatch.
  • No try/catch around JSON.parse.
  • Ignoring quota errors.
  • No schema versioning.

Performance considerations

  • Sync localStorage on the main thread; small reads are fine. Big payloads → IndexedDB (async).

Edge cases

  • Private browsing (storage disabled).
  • Corrupt JSON.
  • Schema drift across releases.
  • Cross-tab sync.

Real-world examples

  • next-themes for theme without flash, zustand/persist with migrate, dexie/idb-keyval for IndexedDB.

Senior engineer discussion

Seniors version schemas, treat storage as untrusted input, and gate hydration to avoid mismatches. They use IndexedDB for non-trivial size or binary data.

Related questions