Skip to content

@stateloom/persist

Declarative state persistence with pluggable storage backends and async hydration.

Install

bash
pnpm add @stateloom/core @stateloom/store @stateloom/persist
bash
npm install @stateloom/core @stateloom/store @stateloom/persist
bash
yarn add @stateloom/core @stateloom/store @stateloom/persist

Size: ~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

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

typescript
import { persist, localStorageBackend } from '@stateloom/persist';

const persistMw = persist({
  key: 'my-app',
  storage: localStorageBackend(),
});

Pass the middleware to createStore:

typescript
import { createStore } from '@stateloom/store';

const store = createStore((set) => ({ count: 0 }), { middleware: [persistMw] });

Storage Adapters

Five built-in adapters cover the most common environments:

AdapterEnvironmentCapacityAsyncUse Case
localStorageBackend()Browser~5-10 MBNoGeneral persistence
sessionStorageBackend()Browser (same tab)~5 MBNoSession-scoped state
cookieStorage(options?)Browser + SSR~4 KBNoAuth tokens, small preferences
indexedDBStorage(options?)BrowserLargeYesLarge state, complex data
memoryStorage()AnyMemoryNoTesting, SSR
typescript
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:

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

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

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

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

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

typescript
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():

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

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

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

ParameterTypeDescriptionDefault
optionsPersistOptions<T>Persistence configuration

Returns: PersistMiddleware<T> — a middleware instance with persistence control methods.

typescript
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 init hook — the store has persisted state immediately after initialization
  • For async storage adapters (IndexedDB), init starts 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.

typescript
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;
}
PropertyTypeDescriptionDefault
keystringUnique key identifying this store in storage— (required)
storageStorageAdapterStorage backendmemoryStorage()
partialize(state: T) => Partial<T>Extract subset of state to persistEntire state
merge(persisted: Partial<T>, current: T) => TMerge persisted state with currentShallow merge
versionnumberSchema version for migration support0
migrate(persisted: Partial<T>, version: number) => Partial<T> | Promise<Partial<T>>Migration function when versions differ
serialize(value: PersistEnvelope<Partial<T>>) => stringCustom serializerJSON.stringify
deserialize(value: string) => PersistEnvelope<Partial<T>>Custom deserializerJSON.parse
onError(error: PersistError) => voidError callback for storage/serialization/migration failures
skipHydrationbooleanSkip automatic hydration on initfalse

See also: persist(), StorageAdapter


PersistMiddleware<T>

Extended middleware type returned by persist(). Includes standard middleware hooks plus persistence control methods.

typescript
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>;
}
MethodDescription
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.
typescript
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.

typescript
interface StorageAdapter {
  getItem(key: string): string | null | Promise<string | null>;
  setItem(key: string, value: string): void | Promise<void>;
  removeItem(key: string): void | Promise<void>;
}
MethodDescription
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.
typescript
// 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().

typescript
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.

typescript
interface PersistEnvelope<T> {
  readonly state: T;
  readonly version: number;
}
PropertyTypeDescription
stateTThe persisted state data (possibly partialized)
versionnumberSchema version for migration support
typescript
// What gets stored in localStorage:
// {"state":{"theme":"dark","locale":"en"},"version":2}

See also: PersistOptions<T>


PersistError

Error descriptor emitted via the onError callback.

typescript
interface PersistError {
  readonly code: PersistErrorCode;
  readonly message: string;
  readonly cause?: unknown;
}
PropertyTypeDescription
codePersistErrorCodeMachine-readable error code
messagestringHuman-readable error message
causeunknownOriginal error, if available

See also: PERSIST_ERROR_CODE


PERSIST_ERROR_CODE

Error codes emitted by the persist middleware.

typescript
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];
CodeWhen
StorageReadFailedgetItem throws or rejects
StorageWriteFailedsetItem throws or rejects
SerializationFailedCustom or default serialize throws
DeserializationFailedCustom or default deserialize throws
MigrationFailedmigrate function throws or rejects
typescript
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.

typescript
import { localStorageBackend } from '@stateloom/persist';

const storage = localStorageBackend();

Key behaviors:

  • Synchronous — hydration completes within the init hook
  • 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.

typescript
import { sessionStorageBackend } from '@stateloom/persist';

const storage = sessionStorageBackend();

Key behaviors:

  • Synchronous — hydration completes within the init hook
  • 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:

ParameterTypeDescriptionDefault
optionsCookieStorageOptions | undefinedCookie attribute configurationundefined

Returns: StorageAdapter — a synchronous adapter backed by cookies.

typescript
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 init hook
  • 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.

typescript
interface CookieStorageOptions {
  readonly maxAge?: number;
  readonly path?: string;
  readonly sameSite?: 'strict' | 'lax' | 'none';
  readonly secure?: boolean;
  readonly domain?: string;
}
PropertyTypeDescriptionDefault
maxAgenumberCookie max-age in seconds— (session cookie)
pathstringCookie path"/"
sameSite'strict' | 'lax' | 'none'SameSite attribute
securebooleanSecure flag (HTTPS only)
domainstringCookie 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:

ParameterTypeDescriptionDefault
optionsIndexedDBStorageOptions | undefinedDatabase configurationundefined

Returns: StorageAdapter — an asynchronous adapter backed by IndexedDB.

typescript
import { indexedDBStorage } from '@stateloom/persist';

const storage = indexedDBStorage({
  dbName: 'my-app',
  storeName: 'state',
  dbVersion: 1,
});

Key behaviors:

  • Asynchronous — hydration starts in init but 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.

typescript
interface IndexedDBStorageOptions {
  readonly dbName?: string;
  readonly storeName?: string;
  readonly dbVersion?: number;
}
PropertyTypeDescriptionDefault
dbNamestringDatabase name"stateloom-persist"
storeNamestringObject store name"keyval"
dbVersionnumberDatabase version1

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.

typescript
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 init hook
  • 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 storage option is provided to persist()

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:

typescript
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 onSet

Custom Storage Adapter

Implement the StorageAdapter interface for any backend:

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

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

typescript
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 onDestroy is 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:

typescript
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.

typescript
import { createStore } from '@stateloom/store';

// No cast needed — structural typing matches the middleware contract
const store = createStore(creator, { middleware: [persistMw] });

When to Use

ScenarioRecommendation
User preferences (theme, locale)persist + localStorageBackend() with partialize
Auth tokenspersist + cookieStorage() (SSR-readable)
Form draft / editor statepersist + indexedDBStorage() (large payloads)
Session-scoped state (wizard progress)persist + sessionStorageBackend()
Cross-tab syncCombine with @stateloom/tab-sync
Undo/redo with persistenceCombine with @stateloom/history
Testingpersist + memoryStorage()
SSR / Node.jspersist + memoryStorage() or custom adapter
Simple ephemeral stateNo persistence needed