useSessionStorage
An SSR-safe sessionStorage-backed state hook, kept in sync across same-tab instances only.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-session-storage.jsonPrompt
Build a React + TypeScript "useSessionStorage" hook (no dependencies beyond
React; uses the browser sessionStorage API and CustomEvent only).
Contract
- `useSessionStorage<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 only for a custom same-origin event dispatched
right after every write, so multiple `useSessionStorage(key)` instances
mounted together in the same tab stay in sync. Do **not** also listen for
the native `"storage"` event the way `useLocalStorage` does — sessionStorage
has no storage area shared with another tab for that event to report on,
so subscribing to it would just be dead code that never fires.
- `getSnapshot()`: read the raw string via `sessionStorage.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 sessionStorage 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
`sessionStorage.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.
- Expiry — extend the stored envelope to `{ value, expiresAt }` and have
`getSnapshot` treat an expired entry as absent, for a short-lived TTL on
top of the tab's own session lifetime (rarely needed since the tab closing
already clears everything, but useful for "expire after N minutes even if
the tab stays open").
- `useLocalStorage` variant — the same subscribe/getSnapshot/getServerSnapshot
shape works against `localStorage` plus a native `"storage"` listener, for
state that should persist and sync across tabs instead of being scoped to
this one.Concepts
- Tab-scoped lifetime, by design — sessionStorage belongs to a single tab's session: closing the tab clears it, and a brand-new tab (even to the exact same URL) starts with an empty store rather than inheriting the previous one's values.
- Cross-tab sync isn't a missing feature, it's an impossible one —
useLocalStoragegets cross-tab sync for free from the native"storage"event because every tab shares one localStorage area per origin; sessionStorage gives each tab its own private area, so there is no "other tab" for that event to notify — the hook simply never subscribes to it. - The custom same-tab event is still required — even without the cross-tab piece, multiple
useSessionStorage(key)instances mounted in the same tab need a signal after every write, since the document that makes the write never receives its own native"storage"event either way. - 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. - 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. useLocalStoragevsuseSessionStorage— pick by how long the state should outlive the user's current visit: persisted preferences and cross-session autosave wantuseLocalStorage; wizard drafts, one-time "seen this visit" flags, and tab-scoped filters that should reset per session wantuseSessionStorage.
useScrollLock
A controlled body-scroll lock hook with measured scrollbar compensation and a body-level lock count that cooperates with overlays carrying their own copy of the lock.
useDocumentTitle
A hook that syncs document.title to a value for as long as a component is mounted, restoring the pre-mount title on unmount.