Hooks
useLocalStorage
An SSR-safe localStorage-backed state hook, kept in sync across same-tab instances and other tabs.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-local-storage.jsonPrompt
Build a React + TypeScript "useLocalStorage" hook (no dependencies beyond
React; uses the browser localStorage API and CustomEvent only).
Contract
- `useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void]`
— same call shape as `useState`.
- `initialValue` is captured once on mount (same semantics as `useState`'s
initial argument): passing a different `initialValue` on a later render
does not overwrite an already-initialized value.
- The setter accepts either a plain value or an updater function `(prev) => next`,
exactly like `useState`'s setter.
Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `useState`:
- `subscribe(callback)`: listen for the native `"storage"` event (fires in
*other* tabs/windows of the same origin when localStorage changes — never
in the tab that made the write) and for a custom same-origin event (fires
in *this* tab so multiple `useLocalStorage(key)` instances mounted
together stay in sync, since the native event skips the writing document).
Both listeners filter by `key` (a `"storage"` event with `key === null`
means `localStorage.clear()` — treat that as "changed" for every key too).
- `getSnapshot()`: read the raw string via `localStorage.getItem(key)` and
`JSON.parse` it, falling back to `initialValue` if the key is absent or
the stored string fails to parse (corrupted or hand-edited storage never
throws).
- `getServerSnapshot()`: return the captured `initialValue` — the server
has no localStorage to read.
- **Stable snapshot reference (the hard part).** `useSyncExternalStore`
requires `getSnapshot` to return a value `===` to the previous call's
result whenever the store hasn't actually changed. `JSON.parse` allocates a
new object/array on every call, so a naive `getSnapshot` that re-parses
every time hands React a "new" snapshot on every render — React concludes
the store is perpetually changing and re-renders in an infinite loop. Fix:
cache the last-seen raw string next to its parsed result (per key); only
re-parse when the raw string itself changed, otherwise return the cached
object reference.
- On `setValue(next)`: resolve `next` (calling it with the current stored
value if it's a function), `JSON.stringify` it, write it via
`localStorage.setItem(key, ...)`, then dispatch the custom same-tab event
with the key so sibling instances re-read immediately. A write that throws
(quota exceeded, storage disabled in private mode) fails silently — no
exception escapes the setter.
- SSR: the server and the client's very first paint both render
`initialValue`; once mounted, `useSyncExternalStore` swaps in the real
stored value on the next commit — no manual hydration-mismatch guard
needed, and no `useEffect` body doing a synchronous `setState`.
Rendering & styling
- The hook renders nothing itself — it returns `[value, setValue]`.
Consumers own all UI; use semantic tokens (`bg-muted`, `text-foreground`,
`text-muted-foreground`) for anything that visualizes the stored value, and
respect `prefers-reduced-motion` for any transition tied to a value change.
Customization levers
- Serializer — swap `JSON.stringify`/`JSON.parse` for a different codec
(e.g. one that handles `Date`/`Map` via a reviver) if the stored value
isn't plain-JSON-safe; keep the same "never throw, fall back to
initialValue" contract.
- `sessionStorage` variant — the same subscribe/getSnapshot/getServerSnapshot
shape works against `sessionStorage`, minus the native cross-tab
`"storage"` event (sessionStorage is per-tab, so only the same-tab custom
event applies).
- Expiry — extend the stored envelope to `{ value, expiresAt }` and have
`getSnapshot` treat an expired entry as absent (return `initialValue` and
optionally clear the key), for a poor-man's TTL cache on top of plain
persistence.Concepts
- Two events, two audiences — the native
"storage"event only ever fires in other tabs/windows, never in the document that made the write; a customCustomEventdispatched right after the write is what lets siblinguseLocalStorage(key)instances in the same tab notice each other. - Stable snapshot reference —
useSyncExternalStoretreats a new object/array reference as "the store changed," even if its contents are identical; caching the last raw string alongside its parsed result (and only re-parsing when the raw string differs) is what keeps repeatedgetSnapshot()calls from looping the component into an infinite re-render. initialValuecaptured once, likeuseState— the value passed on the first render is what's used for the server snapshot and as the parse fallback for the lifetime of the component; passing a different literal on a later render is a no-op, exactly likeuseState's ignored-after-mount initial argument.- Non-throwing on both ends — a corrupted or hand-edited stored string falls back to
initialValueon read, and a failed write (quota exceeded, storage disabled) is swallowed on write; neither path throws, so consumers never need a try/catch around normal usage. - SSR-safe by construction — the server (and the client's first paint) render
initialValueviagetServerSnapshot; the real stored value only appears onceuseSyncExternalStorereads a live snapshot after mount, avoiding a hydration mismatch without any manualtypeof windowguard in the render body.