---
title: 'React API'
description: '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](/guides/react) for setup, examples, snapshots, and server rendering.

:::info[Adapters for other frameworks]
Interested in an adapter for another framework? [File an issue](https://github.com/yangshun/ultrastorage/issues/new) or [open a pull request](https://github.com/yangshun/ultrastorage/pulls).
:::

## `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](#read-options) and [return value](#return-value) as `useStorage`.

:::tip[Recommended for shared storage]
**Prefer `createStorageHook()` for a shared application storage instance.** Use `useStorage()`
directly when a component receives or switches its instance, or when a one-off call needs no named
hook.

Both have the same behavior; the bound hook saves passing the instance on each call. See
[which hook to use](/guides/react#which-hook-should-i-use) for examples and guidance.
:::

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

### Example

Create a shared instance and export its bound hook:

```ts lib/app-storage.ts
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:

```tsx theme-settings.tsx
'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](/guides/react#snapshots-and-expiration).

## `useStorage(storage, key, options?)`

:::tip[Prefer createStorageHook for most usage]
Most of the time, use **`createStorageHook()` instead** to bind your shared storage instance once. Use `useStorage()` directly when a component receives or switches its instance, or when a one-off call does not need a named hook.
:::

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`:

```tsx daily-counter.tsx
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:

```tsx
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:

```tsx
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.

```ts
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](/guides/react#persistence-diagnostics).

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.
