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…

"use client"

import * as React from "react"

/**
 * Custom event name used to sync instances inside one tab. The native "storage" event
 * only fires in *other* tabs/windows — the document that performed the write never
 * hears its own storage event — so multiple `useLocalStorage(key)` instances in one
 * tab notify each other through this custom event.
 */
const SYNC_EVENT = "zyeon:local-storage-sync"

interface SyncEventDetail {
  key: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-local-storage.json

Prompt

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 custom CustomEvent dispatched right after the write is what lets sibling useLocalStorage(key) instances in the same tab notice each other.
  • Stable snapshot referenceuseSyncExternalStore treats 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 repeated getSnapshot() calls from looping the component into an infinite re-render.
  • initialValue captured once, like useState — 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 like useState's ignored-after-mount initial argument.
  • Non-throwing on both ends — a corrupted or hand-edited stored string falls back to initialValue on 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 initialValue via getServerSnapshot; the real stored value only appears once useSyncExternalStore reads a live snapshot after mount, avoiding a hydration mismatch without any manual typeof window guard in the render body.

On This Page