Rooks
Experimental Hooks

useSuspenseLocalStorageState

About

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

A Suspense-enabled hook for localStorage state management with cross-tab synchronization. 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 synchronization across browser tabs.

Examples

Basic Usage with Numbers

import React, { Suspense } from "react";
import { useSuspenseLocalStorageState } from "rooks/experimental";
 
function Counter() {
  const [count, { setItem, deleteItem }] = useSuspenseLocalStorageState(
    'counter',
    (currentValue) => currentValue ? parseInt(currentValue, 10) : 0
  );
 
  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={() => setItem(count + 1)}>
        Increment
      </button>
      <button onClick={() => setItem(count - 1)}>
        Decrement
      </button>
      <button onClick={deleteItem}>
        Reset
      </button>
    </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 { useSuspenseLocalStorageState } from "rooks/experimental";
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
function UserProfile() {
  const [user, { setItem, getItem, deleteItem }] = useSuspenseLocalStorageState(
    'user-profile',
    (currentValue): User => {
      if (currentValue) {
        try {
          return JSON.parse(currentValue);
        } catch {
          return { id: 0, name: 'Guest', email: '' };
        }
      }
      return { id: 0, name: 'Guest', email: '' };
    }
  );
 
  const updateUser = () => {
    setItem({
      id: 123,
      name: 'John Doe',
      email: 'john@example.com'
    });
  };
 
  const getCurrentUser = () => {
    const current = getItem();
    alert(`Current user: ${current.name} (${current.email})`);
  };
 
  return (
    <div>
      <h2>User Profile</h2>
      <p>ID: {user.id}</p>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
      
      <button onClick={updateUser}>
        Update User
      </button>
      <button onClick={getCurrentUser}>
        Show Current User
      </button>
      <button onClick={deleteItem}>
        Clear Profile
      </button>
    </div>
  );
}
 
export default function App() {
  return (
    <Suspense fallback={<div>Loading user profile...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Cross-tab Synchronization Demo

import React, { Suspense } from "react";
import { useSuspenseLocalStorageState } from "rooks/experimental";
 
function SharedCounter() {
  const [count, { setItem, deleteItem }] = useSuspenseLocalStorageState(
    'shared-counter',
    (currentValue) => currentValue ? parseInt(currentValue, 10) : 0
  );
 
  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h3>Shared Counter</h3>
      <p style={{ fontSize: '24px', fontWeight: 'bold' }}>
        Count: {count}
      </p>
      <div style={{ gap: '10px', display: 'flex' }}>
        <button onClick={() => setItem(count + 1)}>
          +1
        </button>
        <button onClick={() => setItem(count + 10)}>
          +10
        </button>
        <button onClick={deleteItem}>
          Reset
        </button>
      </div>
      <p style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
        Open this page in multiple tabs to see real-time synchronization!
      </p>
    </div>
  );
}
 
export default function App() {
  return (
    <Suspense fallback={<div>Initializing shared state...</div>}>
      <SharedCounter />
    </Suspense>
  );
}

Complex State with Arrays

import React, { Suspense } from "react";
import { useSuspenseLocalStorageState } from "rooks/experimental";
 
function TodoList() {
  const [todos, { setItem, deleteItem }] = useSuspenseLocalStorageState(
    'todo-list',
    (currentValue) => {
      if (currentValue) {
        try {
          return JSON.parse(currentValue);
        } catch {
          return [];
        }
      }
      return [];
    }
  );
 
  const addTodo = () => {
    const text = prompt('Enter todo:');
    if (text) {
      setItem([
        ...todos,
        {
          id: Date.now(),
          text,
          completed: false
        }
      ]);
    }
  };
 
  const toggleTodo = (id) => {
    setItem(
      todos.map(todo =>
        todo.id === id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  };
 
  const deleteTodo = (id) => {
    setItem(todos.filter(todo => todo.id !== id));
  };
 
  return (
    <div>
      <h2>Todo List ({todos.length} items)</h2>
      
      <button onClick={addTodo} style={{ marginBottom: '20px' }}>
        Add Todo
      </button>
      
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {todos.map(todo => (
          <li
            key={todo.id}
            style={{
              padding: '10px',
              margin: '5px 0',
              border: '1px solid #ddd',
              borderRadius: '4px',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center'
            }}
          >
            <span
              onClick={() => toggleTodo(todo.id)}
              style={{
                textDecoration: todo.completed ? 'line-through' : 'none',
                cursor: 'pointer',
                flex: 1
              }}
            >
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
      
      {todos.length > 0 && (
        <button onClick={deleteItem} style={{ marginTop: '20px' }}>
          Clear All Todos
        </button>
      )}
    </div>
  );
}
 
export default function App() {
  return (
    <Suspense fallback={<div>Loading todos...</div>}>
      <TodoList />
    </Suspense>
  );
}

With Error Boundary for Production Use

import React, { Suspense } from "react";
import { useSuspenseLocalStorageState } 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 localStorage state.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try Again
          </button>
        </div>
      );
    }
 
    return this.props.children;
  }
}
 
function Settings() {
  const [settings, { setItem, deleteItem }] = useSuspenseLocalStorageState(
    'app-settings',
    (currentValue) => {
      const defaults = {
        theme: 'light',
        notifications: true,
        autoSave: false
      };
      
      if (currentValue) {
        try {
          return { ...defaults, ...JSON.parse(currentValue) };
        } catch {
          return defaults;
        }
      }
      return defaults;
    }
  );
 
  const updateSetting = (key, value) => {
    setItem({
      ...settings,
      [key]: value
    });
  };
 
  return (
    <div>
      <h2>Application Settings</h2>
      
      <div style={{ display: 'grid', gap: '15px', maxWidth: '300px' }}>
        <label>
          Theme:
          <select
            value={settings.theme}
            onChange={(e) => updateSetting('theme', e.target.value)}
            style={{ marginLeft: '10px' }}
          >
            <option value="light">Light</option>
            <option value="dark">Dark</option>
          </select>
        </label>
        
        <label>
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) => updateSetting('notifications', e.target.checked)}
          />
          Enable Notifications
        </label>
        
        <label>
          <input
            type="checkbox"
            checked={settings.autoSave}
            onChange={(e) => updateSetting('autoSave', e.target.checked)}
          />
          Auto Save
        </label>
      </div>
      
      <button onClick={deleteItem} style={{ marginTop: '20px' }}>
        Reset to Defaults
      </button>
    </div>
  );
}
 
export default function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>Loading settings...</div>}>
        <Settings />
      </Suspense>
    </ErrorBoundary>
  );
}

Cross-tab Synchronization

The hook automatically synchronizes state across browser tabs and windows through:

  1. Storage Events: Listens for storage events fired when localStorage is modified in other tabs
  2. Custom Events: Uses custom document events to synchronize state changes within the same tab across multiple hook instances
  3. Real-time Updates: Components automatically re-render when localStorage changes occur from any source

This means if you have the same hook with the same key in multiple tabs or multiple components, they will all stay in sync automatically.

Arguments

ParameterTypeDescription
keystringThe localStorage key to use for storing the data
initializer(currentValue: string | null) => TFunction that transforms the raw localStorage value (string or null) into type T

Initializer Function

The initializer function receives the raw string value from localStorage (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 localStorage 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 localStorage with the new value
deleteItem() => voidRemoves the item from localStorage and resets to initial value

Type Safety

The hook provides full TypeScript generic support:

// Number type
const [count, controls] = useSuspenseLocalStorageState<number>(
  'count',
  (value) => value ? parseInt(value, 10) : 0
);
 
// controls.setItem expects number
controls.setItem(42); // ✅ Valid
controls.setItem('42'); // ❌ Type error
 
// Object type
interface User {
  name: string;
  age: number;
}
 
const [user, userControls] = useSuspenseLocalStorageState<User>(
  'user',
  (value) => value ? JSON.parse(value) : { name: '', age: 0 }
);
 
// userControls.setItem expects User object
userControls.setItem({ name: 'John', age: 30 }); // ✅ Valid
userControls.setItem({ name: 'John' }); // ❌ Type error (missing age)

Error Handling

The hook handles various error scenarios gracefully:

  • localStorage Unavailable: Works in SSR environments or when localStorage is disabled
  • Storage Quota Exceeded: Continues to work even if localStorage 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.

Browser Support

  • Modern Browsers: ✅ Full support
  • Server-Side Rendering: ✅ Works (uses initializer default when localStorage unavailable)
  • Storage Disabled: ✅ Graceful fallback (state works, but won't persist)

Import

import { useSuspenseLocalStorageState } from "rooks/experimental";