useSessionStorage is useState that survives a reload but not a tab close. It works exactly like useState — const [value, setValue] = useSessionStorage(key, initial) — but the value is persisted to sessionStorage, which is scoped to a single tab and wiped when that tab closes. It's the right fit for a multi-step form's progress, a scroll position, or a "seen this modal" flag that shouldn't leak into other tabs the way localStorage does.
Implement useSessionStorage(key, initialValue). On first render, read the stored value (or fall back to initialValue). setValue updates React state and writes to sessionStorage, accepting a value or an updater function. If sessionStorage is unavailable (SSR, private mode, quota), degrade gracefully to plain useState behavior instead of throwing.
function useSessionStorage(key, initialValue) {
// returns [value, setValue] — like useState, backed by sessionStorage.
}
const [step, setStep] = useSessionStorage('checkout-step', 1);
setStep(2); // state updates AND sessionStorage['checkout-step'] = '2'
// reload the tab -> step is 2; close the tab -> gone
const [draft, setDraft] = useSessionStorage('draft', '');
setDraft((d) => d + '!'); // functional updater, like useState
sessionStorage is read once on mount, not on every render. Parse the stored JSON; fall back to initialValue if the key is absent or the JSON is corrupt.setValue must update state and JSON.stringify the value into sessionStorage.setValue(prev => next) like useState.localStorage, sessionStorage is per-tab, so there's no meaningful cross-tab storage event to subscribe to. That's the one piece useLocalStorage has that this hook doesn't need.window is undefined) and fall back gracefully; a failed write must not crash the component.You'll wrap useState so its value is mirrored to sessionStorage — read back on mount, written on every change — and so a missing or broken sessionStorage quietly degrades to plain state instead of crashing.
sessionStorage is localStorage's short-lived sibling: same API, but the data is scoped to one tab and cleared when that tab closes. That makes it ideal for per-tab, per-session state — where you are in a checkout flow, a scroll offset, a dismissed banner — without polluting the user's other tabs. React state alone forgets on reload; sessionStorage remembers within the tab's life. useSessionStorage is the useState-shaped bridge between them, and because storage can be unavailable (private mode, quota, server-side render), it must never throw.
Two things hold the same value and must agree: React state (drives the UI) and sessionStorage (survives a reload of this tab). Seed state from storage once on mount; on every setValue, write both. There's deliberately no cross-tab listener here — sessionStorage isn't shared between tabs, so unlike useLocalStorage there's no storage event worth handling.
The obvious version starts from initialValue and writes on change:
function useSessionStorageNaive(key, initialValue) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
sessionStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Three problems. It ignores the stored value on mount — it always starts at initialValue, so a reload flashes the default and then overwrites what was saved. It throws if sessionStorage is unavailable — private mode or a full quota turns setItem into an exception that crashes the component. And reading in an effect instead of a lazy initializer means the restore happens a render late. We need to read during initialization and guard every access.
const { useState, useCallback } = require('react');
function useSessionStorage(key, initialValue) {
// Read once, in a lazy initializer (SSR-safe, defensive).
const readValue = () => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.sessionStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue; // corrupt JSON, private mode, no storage
}
};
const [value, setValue] = useState(readValue);
// setValue: update state AND persist. Support functional updaters.
const setStoredValue = useCallback(
(next) => {
setValue((prev) => {
const resolved = next instanceof Function ? next(prev) : next;
try {
window.sessionStorage.setItem(key, JSON.stringify(resolved));
} catch {
/* quota / unavailable — keep the state update, skip the write */
}
return resolved;
});
},
[key],
);
return [value, setStoredValue];
}
module.exports = { useSessionStorage };
Two pieces. readValue is passed to useState as a lazy initializer, so it reads sessionStorage exactly once on mount and restores the saved value before the first paint — never re-reading on later renders. setStoredValue resolves a functional updater against the previous state (just like useState), then writes to both React state and sessionStorage in one step. Every storage touch is wrapped in try/catch: a missing window, corrupt JSON, or a QuotaExceededError degrades to the initial value or a skipped write instead of crashing — the state update still lands, so the UI stays responsive even when persistence fails.
Mount useSessionStorage('step', 1) in a tab where sessionStorage['step'] is already "2":
useState(readValue) runs readValue once: getItem('step') is '2', so JSON.parse gives 2. State starts at 2 (not 1) — the saved step is restored on reload.setStep(3) — resolves to 3, writes sessionStorage['step'] = '3', sets state to 3. The UI re-renders; a reload of this tab would restore 3.setStep(s => s + 1) — the updater runs against 3, giving 4; storage becomes '4' and state becomes 4.sessionStorage for this tab is discarded, so a fresh tab starts from initialValue again.The saved value drove the initial render, and each setValue kept state and storage in lockstep.
getItem in the render body re-reads each render and can desync from state. Read it once in a lazy useState(() => ...) initializer.initialValue and writing in an effect overwrites the saved value. Seed state from storage first.setItem throws on quota or in private mode. Wrap it so the state update survives and only the write is skipped.sessionStorage is per-tab; there's no shared storage event to listen for. Adding one is dead code that never fires.useLocalStorage is the same hook against localStorage, plus a storage-event listener for cross-tab sync — the one piece this per-tab version deliberately omits.serialize/deserialize lets you persist Maps, Dates, or Sets that JSON can't round-trip on its own.app:step) avoids collisions when multiple features share one tab's sessionStorage.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useSessionStorage is useState that survives a reload but not a tab close. It works exactly like useState — const [value, setValue] = useSessionStorage(key, initial) — but the value is persisted to sessionStorage, which is scoped to a single tab and wiped when that tab closes. It's the right fit for a multi-step form's progress, a scroll position, or a "seen this modal" flag that shouldn't leak into other tabs the way localStorage does.
Implement useSessionStorage(key, initialValue). On first render, read the stored value (or fall back to initialValue). setValue updates React state and writes to sessionStorage, accepting a value or an updater function. If sessionStorage is unavailable (SSR, private mode, quota), degrade gracefully to plain useState behavior instead of throwing.
function useSessionStorage(key, initialValue) {
// returns [value, setValue] — like useState, backed by sessionStorage.
}
const [step, setStep] = useSessionStorage('checkout-step', 1);
setStep(2); // state updates AND sessionStorage['checkout-step'] = '2'
// reload the tab -> step is 2; close the tab -> gone
const [draft, setDraft] = useSessionStorage('draft', '');
setDraft((d) => d + '!'); // functional updater, like useState
sessionStorage is read once on mount, not on every render. Parse the stored JSON; fall back to initialValue if the key is absent or the JSON is corrupt.setValue must update state and JSON.stringify the value into sessionStorage.setValue(prev => next) like useState.localStorage, sessionStorage is per-tab, so there's no meaningful cross-tab storage event to subscribe to. That's the one piece useLocalStorage has that this hook doesn't need.window is undefined) and fall back gracefully; a failed write must not crash the component.You'll wrap useState so its value is mirrored to sessionStorage — read back on mount, written on every change — and so a missing or broken sessionStorage quietly degrades to plain state instead of crashing.
sessionStorage is localStorage's short-lived sibling: same API, but the data is scoped to one tab and cleared when that tab closes. That makes it ideal for per-tab, per-session state — where you are in a checkout flow, a scroll offset, a dismissed banner — without polluting the user's other tabs. React state alone forgets on reload; sessionStorage remembers within the tab's life. useSessionStorage is the useState-shaped bridge between them, and because storage can be unavailable (private mode, quota, server-side render), it must never throw.
Two things hold the same value and must agree: React state (drives the UI) and sessionStorage (survives a reload of this tab). Seed state from storage once on mount; on every setValue, write both. There's deliberately no cross-tab listener here — sessionStorage isn't shared between tabs, so unlike useLocalStorage there's no storage event worth handling.
The obvious version starts from initialValue and writes on change:
function useSessionStorageNaive(key, initialValue) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
sessionStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Three problems. It ignores the stored value on mount — it always starts at initialValue, so a reload flashes the default and then overwrites what was saved. It throws if sessionStorage is unavailable — private mode or a full quota turns setItem into an exception that crashes the component. And reading in an effect instead of a lazy initializer means the restore happens a render late. We need to read during initialization and guard every access.
const { useState, useCallback } = require('react');
function useSessionStorage(key, initialValue) {
// Read once, in a lazy initializer (SSR-safe, defensive).
const readValue = () => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.sessionStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue; // corrupt JSON, private mode, no storage
}
};
const [value, setValue] = useState(readValue);
// setValue: update state AND persist. Support functional updaters.
const setStoredValue = useCallback(
(next) => {
setValue((prev) => {
const resolved = next instanceof Function ? next(prev) : next;
try {
window.sessionStorage.setItem(key, JSON.stringify(resolved));
} catch {
/* quota / unavailable — keep the state update, skip the write */
}
return resolved;
});
},
[key],
);
return [value, setStoredValue];
}
module.exports = { useSessionStorage };
Two pieces. readValue is passed to useState as a lazy initializer, so it reads sessionStorage exactly once on mount and restores the saved value before the first paint — never re-reading on later renders. setStoredValue resolves a functional updater against the previous state (just like useState), then writes to both React state and sessionStorage in one step. Every storage touch is wrapped in try/catch: a missing window, corrupt JSON, or a QuotaExceededError degrades to the initial value or a skipped write instead of crashing — the state update still lands, so the UI stays responsive even when persistence fails.
Mount useSessionStorage('step', 1) in a tab where sessionStorage['step'] is already "2":
useState(readValue) runs readValue once: getItem('step') is '2', so JSON.parse gives 2. State starts at 2 (not 1) — the saved step is restored on reload.setStep(3) — resolves to 3, writes sessionStorage['step'] = '3', sets state to 3. The UI re-renders; a reload of this tab would restore 3.setStep(s => s + 1) — the updater runs against 3, giving 4; storage becomes '4' and state becomes 4.sessionStorage for this tab is discarded, so a fresh tab starts from initialValue again.The saved value drove the initial render, and each setValue kept state and storage in lockstep.
getItem in the render body re-reads each render and can desync from state. Read it once in a lazy useState(() => ...) initializer.initialValue and writing in an effect overwrites the saved value. Seed state from storage first.setItem throws on quota or in private mode. Wrap it so the state update survives and only the write is skipped.sessionStorage is per-tab; there's no shared storage event to listen for. Adding one is dead code that never fires.useLocalStorage is the same hook against localStorage, plus a storage-event listener for cross-tab sync — the one piece this per-tab version deliberately omits.serialize/deserialize lets you persist Maps, Dates, or Sets that JSON can't round-trip on its own.app:step) avoids collisions when multiple features share one tab's sessionStorage.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.