@stateloom/persist
Declarative state persistence with pluggable storage backends and async hydration.
Install
pnpm add @stateloom/core @stateloom/store @stateloom/persistnpm install @stateloom/core @stateloom/store @stateloom/persistyarn add @stateloom/core @stateloom/store @stateloom/persistSize: ~1.2 KB gzipped (+ core)
Overview
The persist middleware saves store state to a storage backend after every setState call and restores it on initialization. It supports sync adapters (localStorage, sessionStorage, cookies), async adapters (IndexedDB), partial persistence, schema versioning with migrations, custom serialization, and deferred hydration.
Quick Start
import { createStore } from '@stateloom/store';
import { persist, localStorageBackend } from '@stateloom/persist';
const persistMw = persist<{ count: number }>({
key: 'counter',
storage: localStorageBackend(),
});
const store = createStore(
(set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })) }),
{ middleware: [persistMw] },
);
// State is restored from localStorage on creation and saved on every update.Guide
Creating the Middleware
Call persist() with a configuration object. At minimum, provide a key to identify the entry in storage:
import { persist, localStorageBackend } from '@stateloom/persist';
const persistMw = persist({
key: 'my-app',
storage: localStorageBackend(),
});Pass the middleware to createStore:
import { createStore } from '@stateloom/store';
const store = createStore((set) => ({ count: 0 }), { middleware: [persistMw] });Storage Adapters
Five built-in adapters cover the most common environments:
| Adapter | Environment | Capacity | Async | Use Case |
|---|---|---|---|---|
localStorageBackend() | Browser | ~5-10 MB | No | General persistence |
sessionStorageBackend() | Browser (same tab) | ~5 MB | No | Session-scoped state |
cookieStorage(options?) | Browser + SSR | ~4 KB | No | Auth tokens, small preferences |
indexedDBStorage(options?) | Browser | Large | Yes | Large state, complex data |
memoryStorage() | Any | Memory | No | Testing, SSR |
import {
localStorageBackend,
sessionStorageBackend,
cookieStorage,
indexedDBStorage,
memoryStorage,
} from '@stateloom/persist';
// localStorage (default for browsers)
persist({ key: 'app', storage: localStorageBackend() });
// sessionStorage (cleared when tab closes)
persist({ key: 'session', storage: sessionStorageBackend() });
// Cookies (SSR-readable, small payloads)
persist({
key: 'auth',
storage: cookieStorage({ maxAge: 86400, secure: true, sameSite: 'lax' }),
});
// IndexedDB (large state, async hydration)
persist({
key: 'editor',
storage: indexedDBStorage({ dbName: 'my-app', storeName: 'state' }),
});
// In-memory (testing, SSR)
persist({ key: 'test', storage: memoryStorage() });When no storage option is provided, memoryStorage() is used as the default.
Partial State Persistence
Use partialize to persist only a subset of the state. This is useful for excluding functions, derived state, or loading flags:
interface AppState {
theme: 'light' | 'dark';
locale: string;
isLoading: boolean;
setTheme: (theme: 'light' | 'dark') => void;
}
const persistMw = persist<AppState>({
key: 'preferences',
storage: localStorageBackend(),
partialize: (state) => ({
theme: state.theme,
locale: state.locale,
// isLoading and setTheme are excluded
}),
});Merging Strategies
When state is hydrated from storage, it is merged with the current in-memory state. The default merge is a shallow spread ({ ...current, ...persisted }).
Provide a custom merge function for deep merging or special handling:
persist<AppState>({
key: 'app',
storage: localStorageBackend(),
merge: (persisted, current) => ({
...current,
...persisted,
// Deep merge nested objects
settings: { ...current.settings, ...persisted.settings },
}),
});Versioning and Migration
When your state schema changes, bump the version and provide a migrate function. The migrate function receives the persisted state and its version, and returns the updated state:
const persistMw = persist<AppState>({
key: 'app',
storage: localStorageBackend(),
version: 3,
migrate: (persisted, version) => {
if (version < 2) {
// v1 → v2: added locale field
return { ...persisted, locale: 'en' };
}
if (version < 3) {
// v2 → v3: renamed darkMode to theme
const { darkMode, ...rest } = persisted as Record<string, unknown>;
return { ...rest, theme: darkMode ? 'dark' : 'light' } as Partial<AppState>;
}
return persisted;
},
});Migrations can be async — return a Promise<Partial<T>> for migrations that need to fetch data or run async transformations:
persist<AppState>({
key: 'app',
storage: localStorageBackend(),
version: 2,
migrate: async (persisted, version) => {
if (version < 2) {
const defaults = await fetchDefaultSettings();
return { ...defaults, ...persisted };
}
return persisted;
},
});If migration fails, the error is reported via onError and the store continues with its initial in-memory state.
Custom Serialization
Override the default JSON.stringify/JSON.parse serialization with custom functions:
import superjson from 'superjson';
import type { PersistEnvelope } from '@stateloom/persist';
persist<AppState>({
key: 'app',
storage: localStorageBackend(),
serialize: (envelope) => superjson.stringify(envelope),
deserialize: (value) => superjson.parse(value) as PersistEnvelope<Partial<AppState>>,
});Deferred Hydration
By default, hydration runs immediately in the init hook. Set skipHydration: true to hydrate manually — useful for SSR or when you need to wait for an auth token:
const persistMw = persist<AppState>({
key: 'app',
storage: localStorageBackend(),
skipHydration: true,
});
const store = createStore(creator, { middleware: [persistMw] });
// Later, when ready:
await persistMw.rehydrate();Hydration Lifecycle
Track hydration status with hasHydrated() and onHydrate():
const persistMw = persist<AppState>({
key: 'app',
storage: localStorageBackend(),
});
// Check if hydration is complete
persistMw.hasHydrated(); // true after hydration finishes
// Register a callback
const unsub = persistMw.onHydrate((state) => {
console.log('Hydrated with:', state);
});
// If hydration already completed, the callback fires asynchronously via queueMicrotask.
// Call unsub() to remove the callback.Error Handling
All storage, serialization, and migration errors are caught internally — the store always continues working with in-memory state. Use onError to log or report failures:
import type { PersistError } from '@stateloom/persist';
import { PERSIST_ERROR_CODE } from '@stateloom/persist';
persist<AppState>({
key: 'app',
storage: localStorageBackend(),
onError: (error: PersistError) => {
console.error(`[persist:${error.code}] ${error.message}`, error.cause);
if (error.code === PERSIST_ERROR_CODE.StorageWriteFailed) {
// Storage quota exceeded — notify user
showToast('Storage full. Changes may not persist.');
}
},
});Clearing Storage
Remove the persisted entry from storage:
await persistMw.clearStorage();This removes only the key associated with this middleware instance. The store's in-memory state is unaffected.
API Reference
persist<T>(options): PersistMiddleware<T>
Create a persist middleware that saves and restores store state to a storage backend.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | PersistOptions<T> | Persistence configuration | — |
Returns: PersistMiddleware<T> — a middleware instance with persistence control methods.
import { persist, localStorageBackend } from '@stateloom/persist';
const persistMw = persist<{ count: number }>({
key: 'counter',
storage: localStorageBackend(),
});Key behaviors:
- For sync storage adapters (localStorage, sessionStorage), hydration happens synchronously within the
inithook — the store has persisted state immediately after initialization - For async storage adapters (IndexedDB),
initstarts the read and returns — the store state updates when the Promise resolves - All storage and serialization errors are caught — the store always continues with in-memory state
- The extra methods (
rehydrate,hasHydrated,onHydrate,clearStorage) are ignored by the store's middleware pipeline but accessible on the returned object
See also: PersistOptions<T>, PersistMiddleware<T>
PersistOptions<T>
Configuration object for the persist middleware.
interface PersistOptions<T> {
readonly key: string;
readonly storage?: StorageAdapter;
readonly partialize?: (state: T) => Partial<T>;
readonly merge?: (persisted: Partial<T>, current: T) => T;
readonly version?: number;
readonly migrate?: (persisted: Partial<T>, version: number) => Partial<T> | Promise<Partial<T>>;
readonly serialize?: (value: PersistEnvelope<Partial<T>>) => string;
readonly deserialize?: (value: string) => PersistEnvelope<Partial<T>>;
readonly onError?: (error: PersistError) => void;
readonly skipHydration?: boolean;
}| Property | Type | Description | Default |
|---|---|---|---|
key | string | Unique key identifying this store in storage | — (required) |
storage | StorageAdapter | Storage backend | memoryStorage() |
partialize | (state: T) => Partial<T> | Extract subset of state to persist | Entire state |
merge | (persisted: Partial<T>, current: T) => T | Merge persisted state with current | Shallow merge |
version | number | Schema version for migration support | 0 |
migrate | (persisted: Partial<T>, version: number) => Partial<T> | Promise<Partial<T>> | Migration function when versions differ | — |
serialize | (value: PersistEnvelope<Partial<T>>) => string | Custom serializer | JSON.stringify |
deserialize | (value: string) => PersistEnvelope<Partial<T>> | Custom deserializer | JSON.parse |
onError | (error: PersistError) => void | Error callback for storage/serialization/migration failures | — |
skipHydration | boolean | Skip automatic hydration on init | false |
See also: persist(), StorageAdapter
PersistMiddleware<T>
Extended middleware type returned by persist(). Includes standard middleware hooks plus persistence control methods.
interface PersistMiddleware<T> {
readonly name: string;
readonly init?: (api: PersistMiddlewareAPI<T>) => void;
readonly onSet?: (
api: PersistMiddlewareAPI<T>,
next: PersistSetFn<T>,
partial: Partial<T>,
) => void;
readonly onDestroy?: (api: PersistMiddlewareAPI<T>) => void;
rehydrate(): Promise<void>;
hasHydrated(): boolean;
onHydrate(callback: (state: T) => void): () => void;
clearStorage(): Promise<void>;
}| Method | Description |
|---|---|
rehydrate() | Re-read state from storage and merge into the store. Returns a Promise that resolves when hydration completes. |
hasHydrated() | Returns true if hydration has finished (success or failure). |
onHydrate(callback) | Register a callback for when hydration completes. Returns an unsubscribe function. If already hydrated, fires via queueMicrotask. |
clearStorage() | Remove the persisted key from storage. Returns a Promise. |
const persistMw = persist<AppState>({ key: 'app', storage: localStorageBackend() });
const store = createStore(creator, { middleware: [persistMw] });
// Wait for async hydration
await persistMw.rehydrate();
// Check hydration status
if (persistMw.hasHydrated()) {
console.log('Ready');
}
// Listen for hydration
const unsub = persistMw.onHydrate((state) => {
console.log('Hydrated:', state);
});
// Clear persisted data
await persistMw.clearStorage();See also: persist()
StorageAdapter
Contract for synchronous or asynchronous storage backends. The persist middleware detects sync vs async adapters via instanceof Promise on the return value of getItem.
interface StorageAdapter {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}| Method | Description |
|---|---|
getItem(key) | Read a value from storage. Returns null if not found. |
setItem(key, value) | Write a serialized string to storage. |
removeItem(key) | Remove a key from storage. |
// Sync adapter example
const myAdapter: StorageAdapter = {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
};
// Async adapter example
const asyncAdapter: StorageAdapter = {
getItem: async (key) => await db.get(key),
setItem: async (key, value) => {
await db.set(key, value);
},
removeItem: async (key) => {
await db.delete(key);
},
};See also: MemoryStorageAdapter, localStorageBackend()
MemoryStorageAdapter
Extended storage adapter with a clear() method. Returned by memoryStorage().
interface MemoryStorageAdapter extends StorageAdapter {
clear(): void;
}See also: memoryStorage()
PersistEnvelope<T>
Envelope wrapping persisted state with version metadata. This is what gets serialized to storage.
interface PersistEnvelope<T> {
readonly state: T;
readonly version: number;
}| Property | Type | Description |
|---|---|---|
state | T | The persisted state data (possibly partialized) |
version | number | Schema version for migration support |
// What gets stored in localStorage:
// {"state":{"theme":"dark","locale":"en"},"version":2}See also: PersistOptions<T>
PersistError
Error descriptor emitted via the onError callback.
interface PersistError {
readonly code: PersistErrorCode;
readonly message: string;
readonly cause?: unknown;
}| Property | Type | Description |
|---|---|---|
code | PersistErrorCode | Machine-readable error code |
message | string | Human-readable error message |
cause | unknown | Original error, if available |
See also: PERSIST_ERROR_CODE
PERSIST_ERROR_CODE
Error codes emitted by the persist middleware.
const PERSIST_ERROR_CODE = {
StorageReadFailed: 'StorageReadFailed',
StorageWriteFailed: 'StorageWriteFailed',
SerializationFailed: 'SerializationFailed',
DeserializationFailed: 'DeserializationFailed',
MigrationFailed: 'MigrationFailed',
} as const;
type PersistErrorCode = (typeof PERSIST_ERROR_CODE)[keyof typeof PERSIST_ERROR_CODE];| Code | When |
|---|---|
StorageReadFailed | getItem throws or rejects |
StorageWriteFailed | setItem throws or rejects |
SerializationFailed | Custom or default serialize throws |
DeserializationFailed | Custom or default deserialize throws |
MigrationFailed | migrate function throws or rejects |
import { PERSIST_ERROR_CODE } from '@stateloom/persist';
persist<AppState>({
key: 'app',
storage: localStorageBackend(),
onError: (error) => {
if (error.code === PERSIST_ERROR_CODE.StorageWriteFailed) {
console.warn('Storage unavailable, using in-memory state');
}
},
});localStorageBackend()
Create a localStorage-backed storage adapter. Wraps globalThis.localStorage with try/catch for resilience in environments where localStorage is unavailable (private browsing, SSR, quota exceeded).
Returns: StorageAdapter — a synchronous adapter backed by localStorage.
import { localStorageBackend } from '@stateloom/persist';
const storage = localStorageBackend();Key behaviors:
- Synchronous — hydration completes within the
inithook - Silently returns
null/ no-ops if localStorage is inaccessible - Shared across tabs — changes in one tab are visible in another (but not reactive; see
@stateloom/tab-sync)
See also: sessionStorageBackend(), StorageAdapter
sessionStorageBackend()
Create a sessionStorage-backed storage adapter. Same resilience as localStorageBackend() but scoped to the browser tab.
Returns: StorageAdapter — a synchronous adapter backed by sessionStorage.
import { sessionStorageBackend } from '@stateloom/persist';
const storage = sessionStorageBackend();Key behaviors:
- Synchronous — hydration completes within the
inithook - Scoped to the browser tab — data is cleared when the tab closes
- Silently returns
null/ no-ops if sessionStorage is inaccessible
See also: localStorageBackend(), StorageAdapter
cookieStorage(options?): StorageAdapter
Create a cookie-backed storage adapter. Reads and writes document.cookie with URL encoding. Suitable for SSR-compatible persistence of small data (auth tokens, preferences).
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | CookieStorageOptions | undefined | Cookie attribute configuration | undefined |
Returns: StorageAdapter — a synchronous adapter backed by cookies.
import { cookieStorage } from '@stateloom/persist';
const storage = cookieStorage({
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
sameSite: 'lax',
secure: true,
});Key behaviors:
- Synchronous — hydration completes within the
inithook - SSR-readable — cookies are sent with HTTP requests, accessible server-side
- ~4 KB limit — use only for small state
- URL-encodes keys and values for safe storage
See also: CookieStorageOptions, StorageAdapter
CookieStorageOptions
Configuration for the cookieStorage adapter.
interface CookieStorageOptions {
readonly maxAge?: number;
readonly path?: string;
readonly sameSite?: 'strict' | 'lax' | 'none';
readonly secure?: boolean;
readonly domain?: string;
}| Property | Type | Description | Default |
|---|---|---|---|
maxAge | number | Cookie max-age in seconds | — (session cookie) |
path | string | Cookie path | "/" |
sameSite | 'strict' | 'lax' | 'none' | SameSite attribute | — |
secure | boolean | Secure flag (HTTPS only) | — |
domain | string | Cookie domain | — |
indexedDBStorage(options?): StorageAdapter
Create an IndexedDB-backed storage adapter. Lazily opens the database on first use and caches the connection. All operations return Promises.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | IndexedDBStorageOptions | undefined | Database configuration | undefined |
Returns: StorageAdapter — an asynchronous adapter backed by IndexedDB.
import { indexedDBStorage } from '@stateloom/persist';
const storage = indexedDBStorage({
dbName: 'my-app',
storeName: 'state',
dbVersion: 1,
});Key behaviors:
- Asynchronous — hydration starts in
initbut completes later via Promise resolution - Connection pooling — the IDB connection is opened once and reused for all operations
- Large capacity — suitable for complex or large state objects
- Auto-creates the object store on
upgradeneeded
See also: IndexedDBStorageOptions, StorageAdapter
IndexedDBStorageOptions
Configuration for the indexedDBStorage adapter.
interface IndexedDBStorageOptions {
readonly dbName?: string;
readonly storeName?: string;
readonly dbVersion?: number;
}| Property | Type | Description | Default |
|---|---|---|---|
dbName | string | Database name | "stateloom-persist" |
storeName | string | Object store name | "keyval" |
dbVersion | number | Database version | 1 |
memoryStorage(): MemoryStorageAdapter
Create an in-memory storage adapter backed by a Map. Each call returns an independent instance. Useful for testing and SSR environments.
Returns: MemoryStorageAdapter — a synchronous adapter with a bonus clear() method.
import { memoryStorage } from '@stateloom/persist';
const storage = memoryStorage();
const persistMw = persist({ key: 'test', storage });
// After tests:
storage.clear();Key behaviors:
- Synchronous — hydration completes within the
inithook - Each call creates a new independent instance (no shared state)
- The
clear()method removes all entries, not just a single key - Default storage when no
storageoption is provided topersist()
See also: MemoryStorageAdapter, StorageAdapter
Patterns
Combining with Other Middleware
Persist works alongside other middleware. During writes, the middleware chain executes in order — persist writes to storage after the update propagates:
import { createStore } from '@stateloom/store';
import { history } from '@stateloom/history';
import { persist, localStorageBackend } from '@stateloom/persist';
const h = history<AppState>();
const persistMw = persist<AppState>({
key: 'app',
storage: localStorageBackend(),
});
const store = createStore(creator, {
middleware: [h, persistMw],
});
// Undo/redo changes are persisted automatically
h.undo(); // state change goes through persist's onSetCustom Storage Adapter
Implement the StorageAdapter interface for any backend:
import type { StorageAdapter } from '@stateloom/persist';
function createCloudAdapter(baseUrl: string): StorageAdapter {
return {
async getItem(key) {
const res = await fetch(`${baseUrl}/state/${key}`);
if (!res.ok) return null;
return res.text();
},
async setItem(key, value) {
await fetch(`${baseUrl}/state/${key}`, {
method: 'PUT',
body: value,
headers: { 'Content-Type': 'application/json' },
});
},
async removeItem(key) {
await fetch(`${baseUrl}/state/${key}`, { method: 'DELETE' });
},
};
}React Hydration Gate
Show a loading state until async hydration completes:
import { useState, useEffect } from 'react';
function HydrationGate({
persistMw,
children,
fallback = null,
}: {
persistMw: PersistMiddleware<unknown>;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const [hydrated, setHydrated] = useState(persistMw.hasHydrated());
useEffect(() => {
if (hydrated) return;
const unsub = persistMw.onHydrate(() => setHydrated(true));
return unsub;
}, [hydrated, persistMw]);
return hydrated ? children : fallback;
}
// Usage
function App() {
return (
<HydrationGate persistMw={persistMw} fallback={<Spinner />}>
<MainApp />
</HydrationGate>
);
}Multiple Stores with Separate Keys
Each persist() call operates independently. Use different keys to persist multiple stores:
const userPersist = persist<UserState>({
key: 'user-prefs',
storage: localStorageBackend(),
partialize: (s) => ({ theme: s.theme, locale: s.locale }),
});
const editorPersist = persist<EditorState>({
key: 'editor-state',
storage: indexedDBStorage({ dbName: 'editor' }),
});
const userStore = createStore(userCreator, { middleware: [userPersist] });
const editorStore = createStore(editorCreator, { middleware: [editorPersist] });How It Works
Hydration Flow
On store initialization, the init hook reads from storage and merges the persisted state. The flow differs for sync and async adapters:
Persist-on-Write Flow
Every setState call passes through the onSet hook. The middleware calls next(partial) first to propagate the update, then persists the new state:
Connection Pooling (IndexedDB)
The indexedDBStorage adapter opens the database connection lazily on the first operation and caches it. All subsequent reads and writes reuse the same IDBDatabase instance, avoiding the overhead of repeated indexedDB.open() calls.
Error Boundary Behavior
All errors in the persist middleware are caught and handled gracefully:
- Storage read/write failures — reported via
onError, store continues with in-memory state - Serialization/deserialization failures — reported via
onError, hydration completes without applying state - Migration failures (sync or async) — reported via
onError, hydration completes without applying state - Destroyed store — all operations become no-ops after
onDestroyis called
The store is never left in a broken state due to persistence errors.
TypeScript
The persist() function accepts an explicit generic for the store state type:
interface AppState {
count: number;
theme: 'light' | 'dark';
increment: () => void;
}
// Explicit generic
const persistMw = persist<AppState>({
key: 'app',
storage: localStorageBackend(),
partialize: (state) => ({
count: state.count,
theme: state.theme,
// state.increment is available — TypeScript knows the full type
}),
});The returned PersistMiddleware<T> is structurally compatible with Middleware<T> from @stateloom/store. TypeScript's structural typing ensures compatibility without a runtime import of the store package — no type casts are needed when passing it to createStore.
import { createStore } from '@stateloom/store';
// No cast needed — structural typing matches the middleware contract
const store = createStore(creator, { middleware: [persistMw] });When to Use
| Scenario | Recommendation |
|---|---|
| User preferences (theme, locale) | persist + localStorageBackend() with partialize |
| Auth tokens | persist + cookieStorage() (SSR-readable) |
| Form draft / editor state | persist + indexedDBStorage() (large payloads) |
| Session-scoped state (wizard progress) | persist + sessionStorageBackend() |
| Cross-tab sync | Combine with @stateloom/tab-sync |
| Undo/redo with persistence | Combine with @stateloom/history |
| Testing | persist + memoryStorage() |
| SSR / Node.js | persist + memoryStorage() or custom adapter |
| Simple ephemeral state | No persistence needed |