DEV Community

ReactUse
ReactUse

Posted on • Originally published at reactuse.com

React useSessionStorage Hook: Per-Tab State That Survives Reloads (2026)

Here's a checkout flow that loses the customer at step three:

function Checkout() {
  const [step, setStep] = useState(0);
  const [form, setForm] = useState<CheckoutForm>(EMPTY_FORM);
  // step 1: address, step 2: shipping, step 3: payment…
}
Enter fullscreen mode Exit fullscreen mode

The customer fills in their address, picks a shipping option, and on the payment step the provider redirects them out to a 3-D Secure page and back. Or they just hit refresh. Either way, step is 0 again and form is empty. useState lives exactly as long as the component instance does — a reload, a redirect, a full-page navigation, and it's gone.

Everyone knows the fix is Web Storage. Most people reach for localStorage, and it works — until it works too well. The half-finished checkout is now sitting in every tab the customer opens, it's still there next week when they come back for something else, and if they open two tabs to compare shipping options, useLocalStorage faithfully syncs the two forms into each other. What you actually wanted was state that survives this tab's reloads and redirects and then disappears when the tab does. That's sessionStorage, and useSessionStorage from @reactuses/core is the useState-shaped hook for it. This post covers what sessionStorage really promises (and doesn't), when to choose it over localStorage and cookies, the four patterns it's built for, and the gotchas — hydration, tab restore, window.open — that bite the hand-rolled version.

Quick Start

npm install @reactuses/core
Enter fullscreen mode Exit fullscreen mode
import { useSessionStorage } from "@reactuses/core";

function Checkout() {
  const [step, setStep] = useSessionStorage("checkout:step", 0);
  const [form, setForm] = useSessionStorage<CheckoutForm>("checkout:form", EMPTY_FORM);

  return (
    <Wizard step={step ?? 0} onNext={() => setStep(s => (s ?? 0) + 1)}>
      <AddressStep value={form!.address} onChange={address => setForm(f => ({ ...f!, address }))} />
      {/* … */}
    </Wizard>
  );
}
Enter fullscreen mode Exit fullscreen mode

useSessionStorage(key, defaultValue) returns the same [value, setValue] tuple as useState, with the same functional updates. The value is read from sessionStorage on mount, written back on every update, and typed T | nullnull because setValue(null) removes the key (more on that below). Reload the page, get redirected to a payment provider and back, navigate away and hit the browser's back button: step and form are exactly where the customer left them. Close the tab: they're gone, which is the point.

What sessionStorage Actually Promises

The name misleads people into thinking "session" means "logged-in session" or "browser session". It means one top-level browsing context — a tab or window — for one origin. Concretely:

Event Survives?
Reload / hard refresh
Client-side route change (SPA)
Full-page navigation to another page on the same origin
Redirect to a third-party site and back (OAuth, payment, SSO) ✅ — same tab, same origin on return
Browser back / forward
Open the same URL in a new tab ❌ fresh, empty storage
Close the tab ❌ cleared (with a caveat: browsers that restore closed tabs restore its sessionStorage too)
Close the browser

Two edge cases surprise people. First, window.open() copies the opener's sessionStorage into the new window (per the HTML spec, whenever the new window keeps an opener), and Chrome's "Duplicate tab" copies it too — but it's a one-time snapshot, not a live link; the two tabs diverge from then on. Modern browsers open target="_blank" links with noopener by default, so ordinary links start clean. Second, sessionStorage is shared with same-origin iframes in the same tab — they're the same browsing context group — which is the only place the browser's native storage event has any meaning for it (below).

The rest is the same contract as localStorage: synchronous, string-only, roughly 5 MB per origin, and readable by any script on the page — so it's not a security boundary. It's shorter-lived than localStorage, which limits the blast radius of a leak, but XSS reads it just as easily. Anything that must be secret from JavaScript belongs in an httpOnly cookie, not here.

useSessionStorage vs useLocalStorage vs useCookie vs useState

Pick by where the value should live and how long:

You need state that… Reach for
lives as long as the component useState
survives reloads and redirects in this tab, then disappears useSessionStorage
survives browser restarts and stays in sync across tabs useLocalStorage
the server needs on the first request useCookie
is messaged between tabs, not stored useBroadcastChannel

The rule of thumb that resolves 90% of "local or session?" debates: if two tabs showing different values would be a bug, use localStorage; if two tabs showing the same value would be a bug, use sessionStorage. Theme, language, "don't show this again forever" — a user expects those to be one value everywhere, so local. A half-completed form, the filters on this dashboard view, the page you were on before an auth redirect — those belong to one tab, so session.

useSessionStorage and useLocalStorage share the exact same API, serialization, and internals — swap the import and the lifetime changes, nothing else does. Everything in the useLocalStorage deep-dive about hydration, setValue(null), custom serializers and onError applies verbatim, so I'll only recap the parts that matter and spend the rest on the session-specific patterns and gotchas.

What You Get Over the Hand-Rolled Version

Every codebase has a useState initializer that reads storage plus a useEffect that writes it back. Here's what that version gets wrong and useSessionStorage gets right:

  • SSR and hydration. The hook is built on useSyncExternalStore with a server snapshot that returns the default. It never touches window on the server, and the client's first render matches the server HTML, then re-renders with the stored value through the proper path — no crash, no hydration-mismatch warning, no typeof window guard in your code.
  • Serialization by default type. Pass a number and you get a number back; pass an object and it's JSON.stringify/JSON.parse; pass a Map, Set or Date and they round-trip correctly (a plain JSON.stringify(new Map()) gives you {}). Need a specific wire format? Provide serializer: { read, write }.
  • setValue(null) removes the key. "Cleared" is a real state, distinct from "reset to default": after setForm(null) the value is null, and on the next mount it comes back as EMPTY_FORM. That's your "start over" button, and it's why the type is T | null.
  • Corrupted data doesn't crash. Someone edits DevTools, an old deploy wrote a different shape, a JSON.parse throws — the hook returns the default and reports through onError (default console.error) instead of taking the component down.
  • Storage unavailable? Degrades to memory. Some privacy modes and embedded contexts throw on storage access. The hook catches it, calls onError, and behaves like plain useState for the rest of the session.
  • Every component on the same key agrees. Two useSessionStorage("checkout:step", 0) calls — a progress bar in the header, the wizard body — re-render together on every write. The native storage event never fires in the document that made the change, so the hand-rolled version drifts; the hook re-broadcasts each write internally so it can't.

Patterns

Multi-step forms and wizards

The intro's checkout, done properly. Two details worth copying: namespace your keys (checkout:step, checkout:form) so a "start over" can clear them together and unrelated features on the same origin never collide, and store the draft separately from what's been submitted, so a successful order can wipe the draft without touching anything else:

const [step, setStep] = useSessionStorage("checkout:step", 0);
const [draft, setDraft] = useSessionStorage<CheckoutForm>("checkout:form", EMPTY_FORM);

async function submit() {
  await api.placeOrder(draft!);
  setDraft(null); // remove the key — nothing lingers in the tab
  setStep(null);
  navigate("/thank-you");
}
Enter fullscreen mode Exit fullscreen mode

For a large form with a keystroke-per-field update rate, storage writes are synchronous but cheap (a few KB of JSON); if you'd rather batch them, wrap the field updates in useDebounceFn and write the draft on the trailing edge.

Surviving a redirect round-trip

OAuth, SSO, payment providers, "verify your email" links that come back to the app — anything that navigates the tab away and returns needs to stash "where was I?" somewhere that survives a full-page unload but shouldn't be shared with the tab next door. That's sessionStorage's home turf: it's where auth libraries like MSAL keep their PKCE verifier and state by default, for exactly this reason.

function useReturnTo() {
  const [returnTo, setReturnTo] = useSessionStorage<string>("auth:returnTo", null);
  const navigate = useNavigate();

  const stashAndRedirect = () => {
    setReturnTo(window.location.pathname + window.location.search);
    window.location.assign(buildAuthorizeUrl());
  };

  const restore = () => {
    const target = returnTo ?? "/";
    setReturnTo(null); // consume it — one round-trip, one restore
    navigate(target, { replace: true });
  };

  return { stashAndRedirect, restore };
}
Enter fullscreen mode Exit fullscreen mode

Two tabs, two logins, two different returnTos — no cross-talk. Had this been localStorage, tab B's redirect would overwrite tab A's return path.

Per-tab view state that must not sync

The case that catches useLocalStorage fans off guard: a user opens two tabs of the same dashboard to compare "last 7 days" against "last 30 days". With localStorage and cross-tab sync, changing the range in one tab changes it in the other, and the user is left thinking the app is haunted. Any view state that's about this window — filters, sort column, expanded rows, which side panel is open — is a sessionStorage value:

const [range, setRange] = useSessionStorage<"7d" | "30d" | "90d">("dashboard:range", "7d");
Enter fullscreen mode Exit fullscreen mode

Reload preserves it, a second tab starts from the default, and the two never fight. If you also want a persisted "last used" default across sessions, keep that in localStorage and read it as the session default — two hooks, two lifetimes, both explicit.

Once per session

Announcement banners, "we use cookies" notices, an onboarding tooltip — things a user should be able to dismiss for the duration of their visit without you promising to hide them forever:

function ReleaseBanner() {
  const [dismissed, setDismissed] = useSessionStorage("banner:v6.5-dismissed", false);
  if (dismissed) return null;
  return (
    <aside>
      New in v6.5 — <a href="/changelog">see what changed</a>
      <button onClick={() => setDismissed(true)}>Dismiss</button>
    </aside>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ship it in the key (banner:v6.5-dismissed) so a new release gets a fresh banner without touching the old flag. The same shape works for "the user already saw the intro animation this session" — pair it with useReducedMotion if the animation is the kind you should skip anyway.

A stable per-tab ID

sessionStorage is the only browser primitive that naturally gives you "one value per tab that survives reloads". That's precisely what you want for a tab identifier — tagging analytics events, correlating logs, or telling useBroadcastChannel messages apart by sender. mountStorageValue seeds the key on first mount only if it's absent:

const [tabId] = useSessionStorage<string>("tab:id", null, {
  mountStorageValue: () => crypto.randomUUID(),
});
// null on the very first render, then a UUID that's stable across reloads of this tab
Enter fullscreen mode Exit fullscreen mode

Gotchas Worth Knowing

  • The default flashes before the stored value, once. Under SSR the server can't see the browser's storage, so the first paint shows the default and the stored value arrives on the post-hydration render. For a wizard step that's a non-issue; for something like "which panel is open" you may want a skeleton until the value is in. The trade-offs are the same as for localStorage — see SSR-Safe React Hooks.
  • "Cleared when the tab closes" has an asterisk. Chrome, Firefox and Safari all restore sessionStorage when the user reopens a closed tab or the browser restores a session after a crash. Don't rely on tab close as a guaranteed wipe for anything sensitive; if it must go, setValue(null) it yourself.
  • New tab ≠ same tab. Users who Ctrl-click your link into a new tab arrive with empty sessionStorage. That's usually correct (they want a fresh view), but it means "the user has already dismissed the banner" and "the wizard is on step 3" don't carry over. If they should, that's a localStorage value.
  • window.open() copies, then forks. If you window.open() a same-origin popup (a preview, a print view), it starts with a copy of the opener's sessionStorage. Writes in the popup don't reach the opener; use useBroadcastChannel or postMessage if they need to.
  • listenToStorageChanges is mostly moot for sessionStorage. The native storage event only reaches other documents sharing the same area — for sessionStorage, that's same-origin iframes in the same tab, not other tabs. Same-tab sync between components is a separate, always-on mechanism and isn't affected by the option; leave it at the default and forget about it unless you have iframes.
  • Not a vault. It's JavaScript-readable storage. It's fine for a PKCE verifier (single-use, short-lived, and worthless without the authorization code) and for drafts and view state; it's the wrong place for a long-lived access token you'd be upset to see exfiltrated. Server-side sessions and httpOnly cookies exist for that.
  • Storage can be full or blocked. Quota is small and shared with everything else on the origin; some embedded/private contexts throw on access. Both are reported through onError and the hook keeps working in memory. Log it — a "my form reset" bug report often traces back to a QuotaExceededError nobody looked at.
  • The value is T | null, on purpose. After setValue(null) the key is gone and you get null, not the default. If your code can't handle null, either never call setValue(null) (write the default instead) or normalize at the read site: const s = step ?? 0.

When Not to Use useSessionStorage

  • The value should be one thing everywhere, forever (theme, locale, "never show again") → useLocalStorage.
  • The server needs it on the first request (theme without a flash, A/B bucket, auth session) → useCookie.
  • Tabs need to talk, not *store* ("you were logged out in another tab") → useBroadcastChannel.
  • You're keeping a value across renders, not across reloadsuseState, useRef, or useLatest — the previous post in this series covers when each applies.
  • You want it in the URL (shareable filters, deep-linkable steps) → put it in the query string; that beats every storage API when a link should reproduce the view.

Takeaways

  • sessionStorage = one tab, one origin, until the tab closes. It survives reloads, SPA and full-page navigations, back/forward, and redirect round-trips; it does not cross into new tabs (except as a one-time copy via window.open() / duplicate-tab), and browsers may restore it when a closed tab is reopened.
  • useSessionStorage(key, default) is a drop-in useState with that lifetime: same tuple, functional updates, automatic serialization for objects/Maps/Sets/Dates, setValue(null) to remove, onError for corrupt data and blocked storage, SSR-safe via useSyncExternalStore, and every component on the same key stays in sync.
  • Rule of thumb: two tabs disagreeing would be a bug → localStorage; two tabs agreeing would be a bug → sessionStorage. Multi-step forms, redirect round-trips, per-tab view state, once-per-session flags, and per-tab IDs are session values.
  • It's a lifetime, not a security boundary. Keep secrets in httpOnly cookies, and clear sensitive keys yourself with setValue(null) rather than trusting tab close.

useSessionStorage, useLocalStorage, useCookie, and 110+ other SSR-safe, TypeScript-first hooks live in @reactuses/core — one install, tree-shakeable, no dependencies to babysit.

npm install @reactuses/core
Enter fullscreen mode Exit fullscreen mode

Top comments (0)