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.