Rooks
HooksExperimental Hooks

useSuspenseIndexedDBState

Reads and writes IndexedDB state with Suspense support.

About

A Suspense-enabled hook for IndexedDB 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, structured data storage without JSON serialization limits, error handling, and automatic synchronization across browser tabs using BroadcastChannel API.

Examples

Basic usage with simple values

import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

function Counter() {
  const [count, { setItem, deleteItem }] = useSuspenseIndexedDBState(
    "my-counter",
    (currentValue) => (typeof currentValue === "number" ? currentValue : 0)
  );

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setItem(count + 1)}>Increment</button>
      <button onClick={deleteItem}>Reset</button>
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Counter />
    </Suspense>
  );
}

Working with Complex Objects and TypeScript

import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

interface UserProfile {
  id: number;
  name: string;
  email: string;
  preferences: {
    theme: "light" | "dark";
    notifications: boolean;
  };
  createdAt: Date;
}

function UserProfileManager() {
  const [profile, { setItem, getItem, deleteItem }] = useSuspenseIndexedDBState(
    "user-profile",
    (currentValue): UserProfile => {
      if (
        currentValue &&
        typeof currentValue === "object" &&
        "id" in currentValue
      ) {
        // IndexedDB can store Date objects directly, no JSON parsing needed
        return currentValue as UserProfile;
      }
      return {
        id: 0,
        name: "Guest",
        email: "",
        preferences: { theme: "light", notifications: true },
        createdAt: new Date(),
      };
    }
  );

  const updateProfile = () => {
    setItem({
      id: 123,
      name: "John Doe",
      email: "john@example.com",
      preferences: { theme: "dark", notifications: false },
      createdAt: new Date(), // Date objects work directly with IndexedDB
    });
  };

  const getCurrentProfile = () => {
    const current = getItem();
    console.log("Current profile:", current);
  };

  return (
    <div>
      <h2>User Profile</h2>
      <p>Name: {profile.name}</p>
      <p>Email: {profile.email}</p>
      <p>Theme: {profile.preferences.theme}</p>
      <p>Created: {profile.createdAt.toLocaleDateString()}</p>

      <button onClick={updateProfile}>Update Profile</button>
      <button onClick={getCurrentProfile}>Log Current Profile</button>
      <button onClick={deleteItem}>Reset Profile</button>
    </div>
  );
}

Custom Database Configuration

import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

type ItemData = { items: string[] };

function CustomDBExample() {
  const [data, { setItem }] = useSuspenseIndexedDBState<ItemData>(
    "my-data",
    (currentValue) =>
      currentValue &&
      typeof currentValue === "object" &&
      "items" in currentValue
        ? (currentValue as ItemData)
        : { items: [] },
    {
      dbName: "my-app-db",
      storeName: "app-data",
      version: 2,
    }
  );

  return (
    <div>
      <p>Items: {data.items.length}</p>
      <button
        onClick={() =>
          setItem({
            items: [...data.items, `Item ${data.items.length + 1}`],
          })
        }
      >
        Add Item
      </button>
    </div>
  );
}

Storing Binary Data and Complex Structures

import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

interface AppData {
  settings: Map<string, any>; // Maps work with IndexedDB
  files: File[]; // File objects work with IndexedDB
  metadata: {
    version: string;
    lastSync: Date;
    tags: Set<string>; // Sets work with IndexedDB
  };
}

function ComplexDataExample() {
  const [appData, { setItem }] = useSuspenseIndexedDBState(
    "complex-app-data",
    (currentValue): AppData => {
      if (currentValue) {
        return currentValue as AppData;
      }

      const settings = new Map();
      settings.set("theme", "light");
      settings.set("autoSave", true);

      return {
        settings,
        files: [],
        metadata: {
          version: "1.0.0",
          lastSync: new Date(),
          tags: new Set(["important", "project"]),
        },
      };
    }
  );

  const addFile = (file: File) => {
    setItem({
      ...appData,
      files: [...appData.files, file],
      metadata: {
        ...appData.metadata,
        lastSync: new Date(),
      },
    });
  };

  return (
    <div>
      <h2>Complex Data Storage</h2>
      <p>Settings: {appData.settings.size} items</p>
      <p>Files: {appData.files.length}</p>
      <p>Tags: {Array.from(appData.metadata.tags).join(", ")}</p>
      <p>Last Sync: {appData.metadata.lastSync.toLocaleString()}</p>

      <input
        type="file"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) addFile(file);
        }}
      />
    </div>
  );
}

Cross-tab Synchronization

The hook automatically synchronizes state changes across browser tabs using the BroadcastChannel API:

import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

function SyncDemo() {
  const [message, { setItem, deleteItem }] = useSuspenseIndexedDBState<string>(
    "sync-message",
    (currentValue) =>
      typeof currentValue === "string" ? currentValue : "Hello World"
  );

  const [lastUpdated, { setItem: setLastUpdated }] =
    useSuspenseIndexedDBState<Date>("last-updated", (currentValue) =>
      currentValue instanceof Date ? currentValue : new Date()
    );

  const updateMessage = (newMessage: string) => {
    setItem(newMessage);
    setLastUpdated(new Date());
  };

  const clearAll = () => {
    deleteItem();
    setLastUpdated(new Date());
  };

  return (
    <div>
      <h2>Cross-Tab Sync Demo</h2>
      <p>Open this page in multiple tabs to see real-time synchronization!</p>

      <div>
        <strong>Current Message:</strong> {message}
      </div>
      <div>
        <strong>Last Updated:</strong> {lastUpdated.toLocaleTimeString()}
      </div>

      <input
        value={message}
        onChange={(e) => updateMessage(e.target.value)}
        placeholder="Type a message..."
      />

      <button onClick={() => updateMessage("Updated from tab!")}>
        Update Message
      </button>
      <button onClick={clearAll}>Clear Message</button>

      <p>
        <em>
          Try changing the message in one tab and watch it update in others!
        </em>
      </p>
    </div>
  );
}

Parameters

ArgumentTypeDescriptionRequired
keystringThe key to use within the IndexedDB object storeYes
initializer(currentValue: unknown) => TFunction that transforms the raw IndexedDB value into typed stateYes
config{ dbName?: string; storeName?: string; version?: number }Database configurationNo

Configuration fields

PropertyTypeDefaultDescription
dbNamestring"rooks-db"The IndexedDB database name
storeNamestring"state"The object store name within the DB
versionnumber1The database version

Return value

Returns a tuple containing:

  1. currentValue: T - The current state value of type T
  2. controls: object - An object containing:
    • getItem(): T - Get the current value from state
    • setItem(value: T): Promise<void> - Set a new value (async)
    • deleteItem(): Promise<void> - Delete the item and reset to initial value (async)

Type Safety

The hook is fully type-safe and supports TypeScript generics:

// The hook infers the type from your initializer function
const [user, controls] = useSuspenseIndexedDBState(
  'user',
  (value): UserProfile => value || defaultUser
);
// `user` is of type UserProfile
// `controls.setItem` expects UserProfile
// `controls.getItem()` returns UserProfile

// You can also be explicit about the type
const [data, { setItem }] = useSuspenseIndexedDBState<MyDataType>(
  'my-data',
  (value) => value || getDefaultData()
);

Error Handling

The hook handles various error scenarios gracefully:

  • IndexedDB not supported: Falls back to in-memory state
  • Database opening errors: Throws errors that can be caught by error boundaries
  • Transaction errors: Logs errors and maintains existing state
  • Initializer errors: Throws errors that can be caught by error boundaries
import React, { Suspense } from "react";
import { useSuspenseIndexedDBState } from "rooks/experimental";

function ErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
    </ErrorBoundary>
  );
}

IndexedDB vs localStorage Comparison

FeatureuseSuspenseIndexedDBStateuseSuspenseLocalStorageState
Data TypesNative objects, binary dataJSON serializable only
Storage Limit~1GB+ (browser dependent)~5-10MB
PerformanceBetter for large dataBetter for small data
Cross-tab SyncBroadcastChannel APIStorage events
Browser SupportModern browsersAll browsers
Async OperationsYes (Promises)No (synchronous)
Complex ObjectsNative supportJSON serialization required

Browser Support

  • IndexedDB: Supported in all modern browsers (IE 10+)
  • BroadcastChannel: Supported in modern browsers (Chrome 54+, Firefox 38+, Safari 15.4+)
  • Fallback: When BroadcastChannel is not available, cross-tab sync is disabled but the hook still works

Import

import { useSuspenseIndexedDBState } from "rooks/experimental";

Behavior and lifecycle

Initialization is cached by database, store, key, and version and suspends until the database read and initializer finish. Each read/write/delete opens a database connection and closes it when the transaction completes. setItem and deleteItem update local state only after the transaction succeeds, then broadcast to other browsing contexts; the shared BroadcastChannel listener is closed on cleanup.

Compatibility and accessibility

When IndexedDB is missing or the initial read fails, the implementation logs the failure and calls initializer(null) rather than surfacing a storage error. Initializer failures suspend into the nearest error boundary; write and delete failures reject their returned promises. Cross-tab updates require BroadcastChannel support. Treat stored values as untrusted after deployment migrations and do not store secrets merely because the API is origin-scoped.

On this page