DEV3 Technologies home

Safe localStorage state hook

A hydration-safe React hook that persists state in localStorage, survives blocked storage and syncs across browser tabs.

React / Next.jsHooksreactlocalstoragehookssr

Code

import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';

export function useLocalStorageState<T>(
  key: string,
  initial: T,
  validate: (value: unknown) => value is T = (v): v is T => v !== undefined,
): [T, Dispatch<SetStateAction<T>>, boolean] {
  const [value, setValue] = useState<T>(initial);
  const [hydrated, setHydrated] = useState(false);
  const validateRef = useRef(validate);
  validateRef.current = validate;

  const read = useCallback((): T | undefined => {
    try {
      const raw = window.localStorage.getItem(key);
      if (raw === null) return undefined;
      const parsed: unknown = JSON.parse(raw);
      return validateRef.current(parsed) ? parsed : undefined;
    } catch {
      return undefined; // blocked storage or corrupt JSON
    }
  }, [key]);

  // Hydrate after mount so server and first client render match.
  useEffect(() => {
    const stored = read();
    if (stored !== undefined) setValue(stored);
    setHydrated(true);
  }, [read]);

  // Persist changes, but only once the stored value has been loaded.
  useEffect(() => {
    if (!hydrated) return;
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      /* quota exceeded or storage disabled: keep working in memory */
    }
  }, [key, value, hydrated]);

  // Keep other tabs in sync (the event never fires in the writing tab).
  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key !== key) return;
      const next = read();
      if (next !== undefined) setValue(next);
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [key, read]);

  return [value, setValue, hydrated];
}

How it works

What it solves

Reading localStorage while rendering breaks server rendering and causes hydration mismatches, and in private windows or with blocked cookies the call can throw. This hook gives you useState-like ergonomics with persistence that fails soft.

How it works

The first render always uses the default value, so the server and client markup agree. After mount, an effect tries to read and parse the stored JSON; an optional validate function rejects stale or tampered shapes, which matters after you change your data model. A hydrated flag prevents the write effect from overwriting saved data with the default before the read has finished.

Writes happen in an effect whenever the value changes and are wrapped in try/catch so quota errors or disabled storage never crash the UI. A storage event listener updates state when another tab changes the same key, which keeps two open boards in sync. Because the storage event does not fire in the tab that made the change, there is no feedback loop.

The hook returns a third element, hydrated, which you can use to show a skeleton instead of flashing default content.

Adapting it

  • Debounce the write for rapidly changing values such as text drafts.
  • Add a version suffix to the key and migrate old shapes in validate.
  • Swap in sessionStorage by changing one constant.

Pitfalls

  • Never store secrets or tokens here; any script on the page can read them.
  • Objects must be JSON serialisable; dates come back as strings.
  • Browsers cap storage at roughly 5 MB per origin.

How to use it

  1. Save as hooks/useLocalStorageState.ts.
  2. Use in a client component: const [todos, setTodos, ready] = useLocalStorageState<Todo[]>('todos', []).
  3. Pass an optional validator: (v): v is Todo[] => Array.isArray(v).
  4. Show a placeholder until ready is true to avoid a flash of default content.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets