Rooks
HooksExperimental Hooks

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:

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

Parameters

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

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.

On this page