useSuspenseSessionStorageState
Reads and writes sessionStorage state with Suspense support.
About
A Suspense-enabled hook for sessionStorage state management with proper serialization. This hook suspends during initialization and must be wrapped in a Suspense boundary. It provides full TypeScript generic support, proper JSON serialization/deserialization, error handling, and automatic state management for session-scoped data.
Unlike localStorage, sessionStorage data is scoped to the current browser tab/session and doesn't persist across browser restarts.
Examples
Basic Usage with Numbers
import React, { Suspense } from "react";
import { useSuspenseSessionStorageState } from "rooks/experimental";
function Counter() {
const [count, { setItem, deleteItem }] = useSuspenseSessionStorageState(
"counter",
(currentValue) => (currentValue ? parseInt(currentValue, 10) : 0)
);
return (
<div>
<h2>Session Counter: {count}</h2>
<button onClick={() => setItem(count + 1)}>Increment</button>
<button onClick={() => setItem(count - 1)}>Decrement</button>
<button onClick={deleteItem}>Reset</button>
<p style={{ fontSize: "12px", color: "#666" }}>
This counter will reset when you close the browser tab
</p>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<div>Loading counter...</div>}>
<Counter />
</Suspense>
);
}Working with Objects and TypeScript
import React, { Suspense } from "react";
import { useSuspenseSessionStorageState } from "rooks/experimental";
interface FormData {
name: string;
email: string;
message: string;
}
function ContactForm() {
const [formData, { setItem, getItem, deleteItem }] =
useSuspenseSessionStorageState(
"contact-form-draft",
(currentValue): FormData => {
if (currentValue) {
try {
return JSON.parse(currentValue);
} catch {
return { name: "", email: "", message: "" };
}
}
return { name: "", email: "", message: "" };
}
);
const updateField = (field: keyof FormData, value: string) => {
setItem({
...formData,
[field]: value,
});
};
const getCurrentData = () => {
const current = getItem();
console.log("Current form data:", current);
};
const clearDraft = () => {
if (confirm("Clear the form draft?")) {
deleteItem();
}
};
return (
<div style={{ maxWidth: "400px", padding: "20px" }}>
<h2>Contact Form</h2>
<p style={{ fontSize: "12px", color: "#666", marginBottom: "20px" }}>
Form data is automatically saved to sessionStorage as you type
</p>
<div style={{ display: "grid", gap: "15px" }}>
<label>
Name:
<input
type="text"
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
style={{ marginLeft: "10px", padding: "5px" }}
/>
</label>
<label>
Email:
<input
type="email"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
style={{ marginLeft: "10px", padding: "5px" }}
/>
</label>
<label>
Message:
<textarea
value={formData.message}
onChange={(e) => updateField("message", e.target.value)}
rows={4}
style={{ padding: "5px" }}
/>
</label>
</div>
<div style={{ marginTop: "20px", display: "flex", gap: "10px" }}>
<button onClick={getCurrentData}>Log Current Data</button>
<button onClick={clearDraft}>Clear Draft</button>
</div>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<div>Loading form...</div>}>
<ContactForm />
</Suspense>
);
}Session-Scoped Shopping Cart
import React, { Suspense } from "react";
import { useSuspenseSessionStorageState } from "rooks/experimental";
function ShoppingCart() {
const [cart, { setItem, deleteItem }] = useSuspenseSessionStorageState(
"shopping-cart",
(currentValue) => {
if (currentValue) {
try {
return JSON.parse(currentValue);
} catch {
return [];
}
}
return [];
}
);
const addItem = () => {
const name = prompt("Product name:");
const price = parseFloat(prompt("Price:") || "0");
if (name && price > 0) {
setItem([
...cart,
{
id: Date.now(),
name,
price,
quantity: 1,
},
]);
}
};
const updateQuantity = (id, quantity) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItem(
cart.map((item) => (item.id === id ? { ...item, quantity } : item))
);
};
const removeItem = (id) => {
setItem(cart.filter((item) => item.id !== id));
};
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<div style={{ maxWidth: "500px", padding: "20px" }}>
<h2>Shopping Cart (Session Only)</h2>
<p style={{ fontSize: "12px", color: "#666", marginBottom: "20px" }}>
Cart contents will be lost when you close this tab
</p>
<button onClick={addItem} style={{ marginBottom: "20px" }}>
Add Item
</button>
{cart.length === 0 ? (
<p>Your cart is empty</p>
) : (
<>
<ul style={{ listStyle: "none", padding: 0 }}>
{cart.map((item) => (
<li
key={item.id}
style={{
padding: "15px",
margin: "10px 0",
border: "1px solid #ddd",
borderRadius: "8px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<strong>{item.name}</strong>
<div style={{ color: "#666", fontSize: "14px" }}>
${item.price.toFixed(2)} each
</div>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "10px" }}
>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateQuantity(item.id, parseInt(e.target.value))
}
style={{ width: "60px", padding: "5px" }}
/>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
</li>
))}
</ul>
<div
style={{
borderTop: "2px solid #333",
paddingTop: "15px",
marginTop: "20px",
fontSize: "18px",
fontWeight: "bold",
}}
>
Total: ${total.toFixed(2)}
</div>
<button
onClick={deleteItem}
style={{
marginTop: "20px",
backgroundColor: "#ff4444",
color: "white",
}}
>
Clear Cart
</button>
</>
)}
</div>
);
}
export default function App() {
return (
<Suspense fallback={<div>Loading cart...</div>}>
<ShoppingCart />
</Suspense>
);
}Multi-Step Form with Session Persistence
import React, { Suspense } from "react";
import { useSuspenseSessionStorageState } from "rooks/experimental";
function MultiStepForm() {
const [formState, { setItem, deleteItem }] = useSuspenseSessionStorageState(
"multi-step-form",
(currentValue) => {
const defaults = {
step: 1,
personalInfo: { name: "", email: "" },
preferences: { newsletter: false, theme: "light" },
review: { notes: "" },
};
if (currentValue) {
try {
return { ...defaults, ...JSON.parse(currentValue) };
} catch {
return defaults;
}
}
return defaults;
}
);
const updateStep = (step) => {
setItem({ ...formState, step });
};
const updatePersonalInfo = (field, value) => {
setItem({
...formState,
personalInfo: {
...formState.personalInfo,
[field]: value,
},
});
};
const updatePreferences = (field, value) => {
setItem({
...formState,
preferences: {
...formState.preferences,
[field]: value,
},
});
};
const updateReview = (field, value) => {
setItem({
...formState,
review: {
...formState.review,
[field]: value,
},
});
};
const resetForm = () => {
if (confirm("Reset the entire form?")) {
deleteItem();
}
};
return (
<div style={{ maxWidth: "600px", padding: "20px" }}>
<h2>Multi-Step Form</h2>
<p style={{ fontSize: "12px", color: "#666", marginBottom: "20px" }}>
Progress is saved in sessionStorage - refresh the page to see
persistence
</p>
{/* Progress indicator */}
<div style={{ display: "flex", marginBottom: "30px" }}>
{[1, 2, 3].map((step) => (
<div
key={step}
style={{
flex: 1,
height: "4px",
backgroundColor: step <= formState.step ? "#007bff" : "#e9ecef",
marginRight: step < 3 ? "5px" : "0",
}}
/>
))}
</div>
{formState.step === 1 && (
<div>
<h3>Step 1: Personal Information</h3>
<div style={{ display: "grid", gap: "15px", marginBottom: "20px" }}>
<label>
Name:
<input
type="text"
value={formState.personalInfo.name}
onChange={(e) => updatePersonalInfo("name", e.target.value)}
style={{ marginLeft: "10px", padding: "8px" }}
/>
</label>
<label>
Email:
<input
type="email"
value={formState.personalInfo.email}
onChange={(e) => updatePersonalInfo("email", e.target.value)}
style={{ marginLeft: "10px", padding: "8px" }}
/>
</label>
</div>
</div>
)}
{formState.step === 2 && (
<div>
<h3>Step 2: Preferences</h3>
<div style={{ display: "grid", gap: "15px", marginBottom: "20px" }}>
<label>
<input
type="checkbox"
checked={formState.preferences.newsletter}
onChange={(e) =>
updatePreferences("newsletter", e.target.checked)
}
/>
Subscribe to newsletter
</label>
<label>
Theme:
<select
value={formState.preferences.theme}
onChange={(e) => updatePreferences("theme", e.target.value)}
style={{ marginLeft: "10px", padding: "8px" }}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
</div>
</div>
)}
{formState.step === 3 && (
<div>
<h3>Step 3: Review</h3>
<div style={{ marginBottom: "20px" }}>
<h4>Personal Information:</h4>
<p>Name: {formState.personalInfo.name || "Not provided"}</p>
<p>Email: {formState.personalInfo.email || "Not provided"}</p>
<h4>Preferences:</h4>
<p>Newsletter: {formState.preferences.newsletter ? "Yes" : "No"}</p>
<p>Theme: {formState.preferences.theme}</p>
<label>
Additional notes:
<textarea
value={formState.review.notes}
onChange={(e) => updateReview("notes", e.target.value)}
rows={3}
style={{ width: "100%", padding: "8px", marginTop: "5px" }}
/>
</label>
</div>
</div>
)}
{/* Navigation */}
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: "30px",
}}
>
<div>
{formState.step > 1 && (
<button onClick={() => updateStep(formState.step - 1)}>
Previous
</button>
)}
</div>
<div style={{ display: "flex", gap: "10px" }}>
<button
onClick={resetForm}
style={{ backgroundColor: "#dc3545", color: "white" }}
>
Reset Form
</button>
{formState.step < 3 ? (
<button onClick={() => updateStep(formState.step + 1)}>Next</button>
) : (
<button onClick={() => alert("Form submitted!")}>Submit</button>
)}
</div>
</div>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<div>Loading form...</div>}>
<MultiStepForm />
</Suspense>
);
}With Error Boundary for Production Use
import React, { Suspense } from "react";
import { useSuspenseSessionStorageState } from "rooks/experimental";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Something went wrong</h2>
<p>Failed to initialize sessionStorage state.</p>
<button onClick={() => this.setState({ hasError: false })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
function TabSettings() {
const [settings, { setItem, deleteItem }] = useSuspenseSessionStorageState(
"tab-settings",
(currentValue) => {
const defaults = {
fontSize: 16,
showSidebar: true,
language: "en",
};
if (currentValue) {
try {
return { ...defaults, ...JSON.parse(currentValue) };
} catch {
return defaults;
}
}
return defaults;
}
);
const updateSetting = (key, value) => {
setItem({
...settings,
[key]: value,
});
};
return (
<div style={{ padding: "20px" }}>
<h2>Tab-Specific Settings</h2>
<p style={{ fontSize: "12px", color: "#666", marginBottom: "20px" }}>
These settings only apply to this browser tab and session
</p>
<div style={{ display: "grid", gap: "15px", maxWidth: "300px" }}>
<label>
Font Size: {settings.fontSize}px
<input
type="range"
min="12"
max="24"
value={settings.fontSize}
onChange={(e) =>
updateSetting("fontSize", parseInt(e.target.value))
}
style={{ width: "100%" }}
/>
</label>
<label>
<input
type="checkbox"
checked={settings.showSidebar}
onChange={(e) => updateSetting("showSidebar", e.target.checked)}
/>
Show Sidebar
</label>
<label>
Language:
<select
value={settings.language}
onChange={(e) => updateSetting("language", e.target.value)}
style={{ marginLeft: "10px" }}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
</select>
</label>
</div>
<div
style={{
marginTop: "30px",
padding: "20px",
border: "1px solid #ddd",
borderRadius: "8px",
}}
>
<h3>Preview</h3>
<div style={{ fontSize: `${settings.fontSize}px` }}>
Sample text at {settings.fontSize}px
</div>
{settings.showSidebar && (
<div
style={{
marginTop: "10px",
padding: "10px",
backgroundColor: "#f8f9fa",
}}
>
Sidebar content (visible because showSidebar is enabled)
</div>
)}
<div style={{ marginTop: "10px" }}>Language: {settings.language}</div>
</div>
<button onClick={deleteItem} style={{ marginTop: "20px" }}>
Reset to Defaults
</button>
</div>
);
}
export default function App() {
return (
<ErrorBoundary>
<Suspense fallback={<div>Loading settings...</div>}>
<TabSettings />
</Suspense>
</ErrorBoundary>
);
}SessionStorage vs LocalStorage
Key differences between useSuspenseSessionStorageState and useSuspenseLocalStorageState:
| Feature | SessionStorage | LocalStorage |
|---|---|---|
| Persistence | Tab/session only | Survives browser restarts |
| Scope | Single tab | All tabs of same origin |
| Cross-tab Sync | No (tab-specific) | Yes |
| Use Cases | Form drafts, tab settings, temporary data | User preferences, persistent app state |
| Data Lifetime | Until tab is closed | Until explicitly deleted |
Parameters
| Parameter | Type | Description |
|---|---|---|
key | string | The sessionStorage key to use for storing the data |
initializer | (currentValue: string | null) => T | Function that transforms the raw sessionStorage value (string or null) into type T |
Initializer Function
The initializer function receives the raw string value from sessionStorage (or null if not present) and should return the properly typed initial state. This allows for:
- Type Conversion: Convert strings to numbers, parse JSON objects, etc.
- Default Values: Provide fallbacks when sessionStorage is empty
- Data Validation: Validate and sanitize stored data
- Migration: Handle changes in data structure over time
Return value
Returns a tuple [value, controls] where:
| Property | Type | Description |
|---|---|---|
value | T | The current state value |
controls | object | Object containing control methods |
Control Methods
The controls object provides the following methods:
| Method | Type | Description |
|---|---|---|
getItem | () => T | Returns the current state value |
setItem | (value: T) => void | Updates the state and sessionStorage with the new value |
deleteItem | () => void | Removes the item from sessionStorage and resets to initial value |
Type Safety
The hook provides full TypeScript generic support:
// Number type
const [count, controls] = useSuspenseSessionStorageState<number>(
'count',
(value) => value ? parseInt(value, 10) : 0
);
// controls.setItem expects number
controls.setItem(42); // ✅ Valid
controls.setItem('42'); // ❌ Type error
// Object type
interface FormData {
name: string;
email: string;
}
const [formData, formControls] = useSuspenseSessionStorageState<FormData>(
'form',
(value) => value ? JSON.parse(value) : { name: '', email: '' }
);
// formControls.setItem expects FormData object
formControls.setItem({ name: 'John', email: 'john@example.com' }); // ✅ Valid
formControls.setItem({ name: 'John' }); // ❌ Type error (missing email)Error Handling
The hook handles various error scenarios gracefully:
- sessionStorage Unavailable: Works in SSR environments or when sessionStorage is disabled
- Storage Quota Exceeded: Continues to work even if sessionStorage writes fail
- JSON Parse Errors: Lets the initializer handle malformed data
- Initializer Errors: Throws errors to be caught by error boundaries
Always wrap in error boundaries for production use.
Common Use Cases
Form Draft Auto-Save
const [formDraft, { setItem }] = useSuspenseSessionStorageState(
"contact-form-draft",
(value) => (value ? JSON.parse(value) : { name: "", email: "", message: "" })
);
// Auto-save as user types
const handleFieldChange = (field, value) => {
setItem({ ...formDraft, [field]: value });
};Temporary Shopping Cart
const [cart, { setItem, deleteItem }] = useSuspenseSessionStorageState(
"temp-cart",
(value) => (value ? JSON.parse(value) : [])
);
// Cart persists during session but doesn't survive browser restartTab-Specific UI State
const [uiState, { setItem }] = useSuspenseSessionStorageState(
"ui-preferences",
(value) => (value ? JSON.parse(value) : { sidebarOpen: true, theme: "light" })
);Browser Support
- Modern Browsers: ✅ Full support
- Server-Side Rendering: ✅ Works (uses initializer default when sessionStorage unavailable)
- Storage Disabled: ✅ Graceful fallback (state works, but won't persist)
Import
import { useSuspenseSessionStorageState } from "rooks/experimental";Behavior and lifecycle
The first use of a key creates a module-level initialization promise and suspends until initializer returns. State changes are JSON-stringified to sessionStorage from an effect. Same-document instances synchronize through a custom event; a storage listener is also installed and removed on cleanup, although sessionStorage is normally isolated per top-level tab. Deletion removes the key and resets with initializer(null).
Compatibility and accessibility
When sessionStorage is unavailable or blocked, the hook logs storage errors and continues with in-memory state. Initializer failures reach the nearest error boundary. SSR initializes from null; use deterministic fallback markup. Session data is synchronous, quota-limited, cleared with the tab session, visible to same-origin scripts, and unsuitable for secrets.