Rooks
Experimental Hooks

useSuspenseSessionStorageState

About

⚠️ Experimental Hook: This hook may be removed or significantly changed in any release without notice.

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:

FeatureSessionStorageLocalStorage
PersistenceTab/session onlySurvives browser restarts
ScopeSingle tabAll tabs of same origin
Cross-tab SyncNo (tab-specific)Yes
Use CasesForm drafts, tab settings, temporary dataUser preferences, persistent app state
Data LifetimeUntil tab is closedUntil explicitly deleted

Arguments

ParameterTypeDescription
keystringThe sessionStorage key to use for storing the data
initializer(currentValue: string | null) => TFunction 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:

PropertyTypeDescription
valueTThe current state value
controlsobjectObject containing control methods

Control Methods

The controls object provides the following methods:

MethodTypeDescription
getItem() => TReturns the current state value
setItem(value: T) => voidUpdates the state and sessionStorage with the new value
deleteItem() => voidRemoves 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 restart

Tab-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";