Hooks

useSessionStorage

An SSR-safe sessionStorage-backed state hook, kept in sync across same-tab instances only.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Custom event name used to sync instances within one tab. sessionStorage has no
 * "other tab" to sync with by design — every tab owns its own, unshared per-origin
 * session storage area, so the premise the native "storage" event runs on (another
 * document being notified about a shared storage area) simply never holds across
 * tabs, and subscribing to it would wait forever. So this hook (like
 * `useLocalStorage`) notifies the other `useSessionStorage(key)` instances in the
 * same tab through this custom event — the difference being that here, that is the
 * only sync scope there is.
 */

Installation

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

Prompt

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 oneuseLocalStorage gets 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 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.
  • 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.
  • useLocalStorage vs useSessionStorage — pick by how long the state should outlive the user's current visit: persisted preferences and cross-session autosave want useLocalStorage; wizard drafts, one-time "seen this visit" flags, and tab-scoped filters that should reset per session want useSessionStorage.

On This Page