DEV Community

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 (EMPTY_FORM); // step 1: address, step 2: shipping, step 3: payment… } 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 import { useSessionStorage } from "@reactuses/core"; function Checkout() { const [step, setStep] = useSessionStorage("checkout:step", 0); const [form, setForm] = useSessionStorage ("checkout:form", EMPTY_FORM); return ( setStep(s => (s ?? 0) + 1)}> setForm(f => ({ ...f!, address }))} /> {/* … */} ); } 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 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 toucheswindow 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, notypeof 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 aMap ,Set orDate and 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.parse throws - 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 plainuseState 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 nativestorage 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 ("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"); } 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 ("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 }; } Two tabs, two logins, two different returnTo s - 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 ("dashboard:range", "7d"); 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"

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.