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.
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
"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
- 01
useSyncExternalStorerendersinitialValueon the server and during hydration, then switches to the stored value. NouseEffectflash logic or hydration warnings. - 02The snapshot is the raw string, which compares by value. Returning a freshly parsed object from
getSnapshotwould cause an infinite render loop. - 03The
storageevent 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
localStorageis readable by any script on the page.
Need this wired into a real system?
We build and harden production software end to end.
More code helpers
useDebounce hooks for React
Wait until the user stops typing before firing a search or autosave. Includes a value version for derived state and a callback version for event handlers.
fetch with timeout, cancellation and typed errors
fetch has no default timeout and doesn't throw on 4xx/5xx. This small wrapper adds both, and still lets callers cancel with their own AbortSignal.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.