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 | null — null 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 itssessionStorage 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 componentuseState
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
useSyncExternalStorewith a server snapshot that returns the default. It never toucheswindowon 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, notypeof windowguard 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 aMap,SetorDateand they round-trip correctly (a plainJSON.stringify(new Map())gives you{}). Need a specific wire format? Provideserializer: { read, write }. -
setValue(null)removes the key. “Cleared” is a real state, distinct from “reset to default”: aftersetForm(null)the value isnull, and on the next mount it comes back asEMPTY_FORM. That’s your “start over” button, and it’s why the type isT | null. -
Corrupted data doesn’t crash. Someone edits DevTools, an old deploy wrote a different shape, a
JSON.parsethrows — the hook returns the default and reports throughonError(defaultconsole.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 plainuseStatefor 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 nativestorageevent 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
sessionStoragewhen 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 alocalStoragevalue. -
window.open()copies, then forks. If youwindow.open()a same-origin popup (a preview, a print view), it starts with a copy of the opener’ssessionStorage. Writes in the popup don’t reach the opener; useuseBroadcastChannelorpostMessageif they need to. -
listenToStorageChangesis mostly moot for sessionStorage. The nativestorageevent only reaches other documents sharing the same area — forsessionStorage, 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
httpOnlycookies 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
onErrorand the hook keeps working in memory. Log it — a “my form reset” bug report often traces back to aQuotaExceededErrornobody looked at. -
The value is
T | null, on purpose. AftersetValue(null)the key is gone and you getnull, not the default. If your code can’t handlenull, either never callsetValue(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 reloads →
useState,useRef, oruseLatest— 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 viawindow.open()/ duplicate-tab), and browsers may restore it when a closed tab is reopened. -
useSessionStorage(key, default)is a drop-inuseStatewith that lifetime: same tuple, functional updates, automatic serialization for objects/Maps/Sets/Dates,setValue(null)to remove,onErrorfor corrupt data and blocked storage, SSR-safe viauseSyncExternalStore, 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
httpOnlycookies, and clear sensitive keys yourself withsetValue(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