Skip to content
ultrastorageultrastorage
Esc
↑↓navigate↵open⌘Jpreview
On this page

React API

React hook signatures, read options, return values, and exported types.

Import the React adapter from ultrastorage/react. React 18 and 19 are supported. See the React guide for setup, examples, snapshots, and server rendering.

createStorageHook(storage: UltraStorage): StorageHook

Binds a storage instance and returns a hook that accepts (key, options?).

The bound hook uses that instance’s configured Storage object, prefix, separator, and serializer, with the same read options and return value as useStorage.

Create the bound hook outside components; no provider is required.

Example

Create a shared instance and export its bound hook:

import { createStorage } from 'ultrastorage';
import { createStorageHook } from 'ultrastorage/react';

export const appStorage = createStorage({ prefix: 'app' });
export const useAppStorage = createStorageHook(appStorage);

Components can read and update the same key without passing the instance. This example uses Zod for the schema option, a display-only defaultValue, and ttl on a write:

'use client';

import { z } from 'zod';
import { useAppStorage } from './lib/app-storage';

const ThemeSchema = z.enum(['light', 'dark']);

export function ThemePicker() {
  const [theme, setTheme, removeTheme] = useAppStorage('theme', {
    schema: ThemeSchema,
    defaultValue: 'light',
  });

  return (
    <>
      <button onClick={() => setTheme((previous) => (previous === 'light' ? 'dark' : 'light'))}>
        Theme: {theme}
      </button>
      <button onClick={() => setTheme('dark', { ttl: 60 * 60 * 1000 })}>
        Use dark theme for one hour
      </button>
      <button onClick={removeTheme}>Reset theme</button>
    </>
  );
}

export function CurrentTheme() {
  const [theme] = useAppStorage('theme', {
    schema: ThemeSchema,
    defaultValue: 'light',
  });
  return <p>Current theme: {theme}</p>;
}

Mount both components anywhere in the app. Toggling the theme updates both, and resetting it removes the stored entry so both display 'light'. They also respond to appStorage.setItem('theme', 'dark') outside React.

Both consumers use the same schema and default so they agree on invalid or missing data. The schema validates stored values on read; the default is never automatically written.

The one-hour button passes ttl to the setter. Expiration is lazy by default, so elapsed time alone does not trigger a render; see snapshots and expiration.

useStorage(storage, key, options?)

Subscribes to a key and returns its current value with callbacks to write or remove it. Calling this hook directly is equivalent to calling a bound hook.

Use useStorage() when choosing the storage instance is part of the component’s job:

  • The instance comes from props or context. A reusable component can work with whichever store its caller supplies, including a memory-backed instance in tests.
  • The instance can change. For example, a workspace picker can select among existing storage instances with different prefixes. Passing the selected instance to useStorage() switches the subscription.
  • You only need a one-off call. A component can pass an existing instance directly without defining and exporting a named hook.

Keep the instance stable between renders unless you intend to switch stores. When several components always use the same shared instance, prefer createStorageHook() to avoid passing it repeatedly.

Both APIs support changing keys, so a dynamic key alone does not require useStorage().

Parameter Type Description
storage UltraStorage Factory-created instance from ultrastorage or ultrastorage/core.
key StorageKey String or ordered array of string segments relative to the namespace.
options UseStorageOptions<T> Optional display fallback and synchronous read schema.

Here, DailyCounter receives its instance through props, so its parent chooses where the count is stored. The direct hook accepts the same read options as a bound hook.

Write options go on the setter; this example uses an absolute expiresAt instead of a ttl:

import type { UltraStorage } from 'ultrastorage';
import { useStorage } from 'ultrastorage/react';
import { z } from 'zod';

const CountSchema = z.number().int().nonnegative();

export function DailyCounter({ storage }: { storage: UltraStorage }) {
  const [count, setCount, removeCount] = useStorage(storage, 'count', {
    schema: CountSchema,
    defaultValue: 0,
  });

  function increment() {
    const midnight = new Date();
    midnight.setHours(24, 0, 0, 0);
    setCount((previous) => previous + 1, { expiresAt: midnight });
  }

  return (
    <>
      <button onClick={increment}>Count: {count}</button>
      <button onClick={removeCount}>Reset count</button>
    </>
  );
}

count and the updater’s previous value are inferred as number from the schema and default. Each increment sets expiry to the next local midnight.

Expiration is lazy here too; the counter does not automatically rerender at midnight. Use either ttl or expiresAt on a write, never both. Omitting expiration options preserves the current unexpired deadline; expiresAt: null removes expiration.

Read options

Option Behavior
defaultValue Display-only fallback when the read returns null; never automatically written. An omitted or undefined default uses null.
reactiveExpiration Optional boolean, default false. Update at expiration without deleting data; timers may run late.
diagnostics Optional boolean, default false. Add read outcomes and the last mutation error as a fourth tuple item.
writeOptions Default write expiration. An explicit setter expiration replaces the whole default policy.
schema Synchronous Standard Schema validation and transformation, matching getItem(). Infer the value type from its output.

Missing, expired, foreign, unparseable, invalid, and stored-null values use the fallback. Valid stored undefined remains undefined. A non-null default removes null from the inferred value and updater input types.

Defaults should match the schema output type; defaults themselves are not validated. Setter input uses that same output type, and writes are not schema-validated.

Return value

useStorage() and the bound hook return a readonly [value, setValue, removeValue] tuple by default. diagnostics: true adds a fourth item without changing setter/remover behavior. setValue(valueOrUpdater, storageOptions?) accepts ttl or expiresAt, never both. Omitted setter expiration uses options.writeOptions, if supplied. Without a hook default, it preserves the current unexpired deadline. Pass expiresAt: null to remove expiration. Missing or expired entries are written without expiration when no policy is supplied.

Functional updates read the latest persisted value, apply schema validation and the fallback, then write the result. They are synchronous but are not atomic across tabs. removeValue() removes the entry and restores the displayed default.

Instance and subscription behavior

Setter and remover identities remain stable while the storage instance and structural key stay the same. Equivalent array keys do not switch subscriptions when recreated during render.

Changes to hook options apply after commit; switching instances or keys switches subscriptions.

Use factory-created ultrastorage instances from ultrastorage or ultrastorage/core; structural mocks or independently loaded package copies are not supported by the private snapshot bridge. For tests, wrap createMemoryStorage() with createStorage().

Default write options

Declare expiration once for this key’s writes, on useStorage() or a bound hook:

const [filter, setFilter] = useStorage(appStorage, 'filter', {
  defaultValue: 'all',
  writeOptions: { ttl: 86_400_000 },
});

setFilter('unread'); // One day from this write.
setFilter('all', { expiresAt: null }); // Replace the default TTL with no expiration.

A setter’s explicit ttl or expiresAt (including null) replaces the entire hook default. Omitted options, {}, or options containing only undefined expiration fields use the default. Without either policy, the core preserves the existing unexpired deadline.

Specifying both fields at the same level is an error. A default TTL and setter expiresAt, or default expiresAt and setter TTL, do not conflict. Default TTL restarts only on writes through that hook; reads, rerenders, and hydration never refresh expiration. Defaults belong to each hook call, so share the configuration when several components should use the same policy.

Diagnostics

Enable diagnostics on the existing hook to receive a fourth tuple item:

const [draft, setDraft, removeDraft, diagnostics] = useStorage(appStorage, 'draft', {
  defaultValue: '',
  diagnostics: true,
});

The same option works with createStorageHook()’s returned hook. No additional hook or factory is required. A literal true infers the four-item tuple; false or omitted returns the original three. A dynamic boolean produces a union of those tuple types, so the fourth item can be undefined.

interface StorageDiagnostics<T> {
  readResult: { readonly __result: true } & (
    | StorageReadResult<T>
    | { status: 'pending' }
    | { status: 'unavailable'; error: unknown }
    | { status: 'read-error'; error: unknown }
  );
  lastError: { operation: 'write' | 'remove'; error: unknown } | null;
}

readResult always carries __result: true and preserves the exact successful value, including null and undefined. The displayed value retains the existing fallback policy, including for stored null. Read outcomes are:

Status Meaning
pending SSR and initial hydration, before reading storage.
success Decoded and validated data in value, including nullish values.
missing No stored entry.
expired The deadline in expiresAt has passed; React reads retain the bytes.
unsupported Foreign data or an invalid/unsupported envelope.
parse-error Parsing failed under this instance’s fallback policy; includes error.
validation-error The schema returned issues.
unavailable Backend access failed; includes error.
read-error A schema or parser contract threw, including unsupported async behavior; includes error.

With diagnostics enabled, thrown read failures become outcomes and the displayed value uses the fallback. Without diagnostics, these errors continue to propagate. All hooks require a factory-created storage instance and valid key.

Mutations remain synchronous, return void, and throw on failure in both modes. Diagnostics record the original error in lastError before rethrowing it; callers still need try/catch. Failed writes do not optimistically change the displayed value. The next successful write or removal clears the error. Errors belong to this hook and key, not other hook consumers.

Diagnostics do not block writes, validate setter input, or retain unsaved text. Functional updates still use the fallback for invalid data and can replace it. Draft UIs should check the read outcome before saving, keep edited text in component state, and handle conflicts themselves. These outcomes describe the current rendered read; writes are not atomic across tabs. See the draft example.

Backend access is retried on subsequent renders. If an unavailable backend recovers, the hook also reconnects its subscription. There is no polling or separate retry API. Unmounting and switching keys release subscriptions and timers. Setter/remover identities remain stable when toggling diagnostics for the same storage instance and structural key.

Exported types

The adapter exports UseStorageOptions, UseStorageResult, StorageSetter, StorageHook, and StorageDiagnostics. UseStorageResult<T, Current, true> includes diagnostics; the third parameter defaults to false for the original tuple type. It does not add a provider, public snapshot API, selector, or useStorageValue hook.

Was this page helpful?