All code helpers
FrontendTypeScript

SSR-safe useLocalStorage hook

Persist small bits of UI state in localStorage without hydration mismatches in Next.js. Built on useSyncExternalStore, so it also stays in sync across components and browser tabs.

#react#hooks#nextjs#ssr
hooks/use-local-storage.ts
import { useCallback, useMemo, useSyncExternalStore } from "react";

const LOCAL_EVENT = "local-storage-change";

function subscribe(onChange: () => void) {
  window.addEventListener("storage", onChange); // other tabs
  window.addEventListener(LOCAL_EVENT, onChange); // this tab
  return () => {
    window.removeEventListener("storage", onChange);
    window.removeEventListener(LOCAL_EVENT, onChange);
  };
}

function readRaw(key: string): string | null {
  try {
    return window.localStorage.getItem(key);
  } catch {
    return null; // storage blocked (privacy mode, sandboxed iframe)
  }
}

function parse<T>(raw: string | null, fallback: T): T {
  if (raw === null) return fallback;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return fallback;
  }
}

export function useLocalStorage<T>(key: string, initialValue: T) {
  // Server snapshot is null, so the first client render matches the HTML
  const raw = useSyncExternalStore(subscribe, () => readRaw(key), () => null);
  const value = useMemo(() => parse(raw, initialValue), [raw, initialValue]);

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const prev = parse(readRaw(key), initialValue);
      const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
      try {
        window.localStorage.setItem(key, JSON.stringify(resolved));
      } catch {
        // quota exceeded or storage blocked: keep the UI working without persistence
      }
      window.dispatchEvent(new Event(LOCAL_EVENT));
    },
    [key, initialValue],
  );

  const remove = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
    } catch {}
    window.dispatchEvent(new Event(LOCAL_EVENT));
  }, [key]);

  return [value, setValue, remove] as const;
}

Usage

TSX
"use client";
import { useLocalStorage } from "@/hooks/use-local-storage";

export function SidebarToggle() {
  const [collapsed, setCollapsed] = useLocalStorage("sidebar-collapsed", false);
  return (
    <button onClick={() => setCollapsed((c) => !c)} aria-pressed={collapsed}>
      {collapsed ? "Expand" : "Collapse"} sidebar
    </button>
  );
}

How it works & gotchas

  • 01useSyncExternalStore renders initialValue on the server and during hydration, then switches to the stored value. No useEffect flash logic or hydration warnings.
  • 02The snapshot is the raw string, which compares by value. Returning a freshly parsed object from getSnapshot would cause an infinite render loop.
  • 03The storage event only fires in other tabs, so a custom event keeps components in the same tab in sync.
  • 04Pass a primitive or a module-level constant as initialValue. An inline object literal works but re-parses on every render.
  • 05Don't store tokens or personal data here. Anything in localStorage is readable by any script on the page.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer