Skip to content

Persist Design

Low-level design for @stateloom/persist — the storage persistence middleware. Covers the hydration flow, write pipeline, StorageAdapter interface, built-in adapters, versioned envelope format, migration system, error resilience, and extended API.

Overview

The @stateloom/persist package saves and restores store state to/from a storage backend. It handles both synchronous adapters (localStorage, sessionStorage, cookies) and asynchronous adapters (IndexedDB) through a unified interface. The middleware wraps each setState call to persist the new state, and on initialization reads persisted state back into the store.

Hydration Flow

Hydration is the process of reading persisted state from storage and merging it into the store. It occurs during the init hook (unless skipHydration: true is set).

Hydration Steps

  1. Readstorage.getItem(key) returns the raw serialized string (sync or async)
  2. Deserialize — Parse the raw string into a PersistEnvelope<Partial<T>>
  3. Version check — Compare envelope.version with the configured version
  4. Migrate — If versions differ and migrate is defined, transform the persisted state
  5. Merge — Combine persisted state with current store state via merge(persisted, current)
  6. Apply — Call api.setState(merged) to update the store
  7. Finish — Set isHydrated = true and fire registered onHydrate callbacks

Sync vs Async Detection

The middleware detects synchronous vs asynchronous adapters via instanceof Promise on the return value:

typescript
const result = storage.getItem(key);

if (result instanceof Promise) {
  result.then(
    (raw) => { handleStorageValue(raw); },
    (err) => { reportError(...); finishHydration(); },
  );
} else {
  handleStorageValue(result);
}

This duck-typing approach means any adapter that returns a Promise gets async handling, while plain values get synchronous handling — no configuration flag needed.

Write Flow

On each onSet, the middleware calls next(partial) first (letting the state update propagate through the chain), then persists the new state:

Why Write After next()

The onSet hook calls next(partial) before persistState(). This ensures the state is fully updated before reading it for persistence. If persist wrote before next(), it would serialize the old state.

typescript
onSet(_api: PersistMiddlewareAPI<T>, next: PersistSetFn<T>, partial: Partial<T>): void {
  next(partial);
  persistState();
}

StorageAdapter Interface

The StorageAdapter interface is the abstraction boundary between the persist middleware and platform storage:

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>;
}

The interface intentionally mirrors the Web Storage API (localStorage.getItem/setItem/removeItem) with added Promise support for async backends. This makes it trivial to wrap any key-value store.

Built-in Adapters

AdapterSync/AsyncPlatformUse Case
localStorageBackend()SyncBrowserGeneral persistence across tabs
sessionStorageBackend()SyncBrowserSession-scoped persistence
cookieStorage(options)SyncBrowserSSR-compatible small data (auth tokens)
indexedDBStorage(options)AsyncBrowserLarge state objects (exceeds 5 MB localStorage limit)
memoryStorage()SyncUniversalTesting, SSR fallback

Error Wrapping

The localStorageBackend() and sessionStorageBackend() adapters wrap all operations in try/catch blocks. This handles:

  • Private browsing mode (where storage may throw)
  • Quota exceeded errors
  • SSR environments where localStorage is undefined
typescript
getItem(key: string): string | null {
  try {
    return localStorage.getItem(key);
  } catch {
    return null;
  }
}

IndexedDB Lazy Connection

The indexedDBStorage() adapter lazily opens the database on first use and caches the connection:

typescript
let dbPromise: Promise<IDBDatabase> | undefined;

function getDB(): Promise<IDBDatabase> {
  if (dbPromise !== undefined) return dbPromise;
  dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(dbName, dbVersion);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(objectStoreName)) {
        db.createObjectStore(objectStoreName);
      }
    };
    request.onsuccess = () => {
      resolve(request.result);
    };
    request.onerror = () => {
      reject(request.error ?? new Error('IndexedDB request failed'));
    };
  });
  return dbPromise;
}

The onupgradeneeded handler creates the object store if it doesn't exist, making the adapter self-initializing.

Memory Storage

memoryStorage() is backed by a Map<string, string> and returns a fresh independent instance per call. It includes a bonus clear() method for test cleanup:

typescript
function memoryStorage(): MemoryStorageAdapter {
  const store = new Map<string, string>();
  return {
    getItem: (key) => store.get(key) ?? null,
    setItem: (key, value) => {
      store.set(key, value);
    },
    removeItem: (key) => {
      store.delete(key);
    },
    clear: () => {
      store.clear();
    },
  };
}

Envelope Format

Persisted data is wrapped in a versioned envelope:

typescript
interface PersistEnvelope<T> {
  readonly state: T;
  readonly version: number;
}

The envelope serializes to JSON as:

json
{
  "state": { "count": 5, "name": "Alice" },
  "version": 2
}

This structure enables schema migration by comparing the persisted version against the configured version.

Custom Serialization

The serialize and deserialize options allow replacing JSON with custom formats (e.g., superjson for Date, Map, Set support):

typescript
const middleware = persist({
  key: 'app',
  serialize: (envelope) => superjson.stringify(envelope),
  deserialize: (raw) => superjson.parse(raw),
});

Migration Flow

When envelope.version !== options.version and a migrate function is provided:

Migration can be synchronous or asynchronous — the middleware detects via instanceof Promise, matching the pattern used for storage adapter detection.

If versions differ but no migrate function is provided, the persisted data is silently discarded and hydration finishes without applying state.

Error Resilience

All storage, serialization, deserialization, and migration errors are caught. The store always continues working with in-memory state. Errors are reported via the optional onError callback with structured error codes:

typescript
const PERSIST_ERROR_CODE = {
  StorageReadFailed: 'StorageReadFailed',
  StorageWriteFailed: 'StorageWriteFailed',
  SerializationFailed: 'SerializationFailed',
  DeserializationFailed: 'DeserializationFailed',
  MigrationFailed: 'MigrationFailed',
} as const;
Error CodeCauseRecovery
StorageReadFailedgetItem threw or rejectedHydration finishes without persisted state
StorageWriteFailedsetItem threw or rejectedState update succeeds in memory; write skipped
SerializationFailedserialize threwWrite skipped; state remains in memory
DeserializationFaileddeserialize threwHydration finishes without persisted state
MigrationFailedmigrate threw or rejectedHydration finishes without persisted state

The error reporting function:

typescript
function reportError(code: PersistError['code'], message: string, cause?: unknown): void {
  onError?.({ code, message, cause });
}

Extended API

The PersistMiddleware<T> type extends the standard middleware hooks with persistence control methods:

MethodPurpose
rehydrate()Re-read from storage and merge into the store. Returns a Promise<void>
hasHydrated()Returns true if hydration has completed (success or failure)
onHydrate(callback)Register a callback for hydration completion. Returns an unsubscribe function
clearStorage()Remove the persisted key from storage. Returns a Promise<void>

These methods are ignored by the store's middleware pipeline (which only calls init, onSet, onDestroy) but are accessible to consumers who hold a reference to the middleware object.

rehydrate() Implementation

typescript
rehydrate(): Promise<void> {
  if (isDestroyed || api === undefined) return Promise.resolve();
  isHydrated = false;
  return new Promise<void>((resolve) => {
    const unsub = middleware.onHydrate(() => {
      unsub();
      resolve();
    });
    performHydration();
  });
}

The method resets isHydrated to false, registers a one-shot onHydrate callback that resolves the returned Promise, and triggers performHydration().

onHydrate() Late Registration

If onHydrate is called after hydration has already completed, the callback fires asynchronously via queueMicrotask:

typescript
onHydrate(callback: (state: T) => void): () => void {
  hydrateCallbacks.add(callback);
  if (isHydrated) {
    queueMicrotask(() => {
      if (api !== undefined && hydrateCallbacks.has(callback)) {
        callback(api.getState());
      }
    });
  }
  return () => { hydrateCallbacks.delete(callback); };
}

The guard hydrateCallbacks.has(callback) prevents firing if the callback was unsubscribed between registration and the microtask execution.

Closed-Over State

The persist() function returns a plain object whose methods close over shared mutable state:

VariableTypePurpose
apiPersistMiddlewareAPI<T> | undefinedStore API reference, set during init
isHydratedbooleanWhether hydration has completed
isDestroyedbooleanWhether the store has been destroyed
hydrateCallbacksSet<(state: T) => void>Registered hydration completion callbacks

Design Decisions

Why Structural Typing for PersistMiddlewareAPI

The @stateloom/persist package declares its own PersistMiddlewareAPI<T> that structurally matches the store's MiddlewareAPI<T>. This keeps persist in the middleware layer with only @stateloom/core as a peer dependency.

Why Write After next()

Persisting state before next() would serialize the old state. Writing after ensures the state has been fully updated through the entire middleware chain.

Why instanceof Promise for Async Detection

Rather than requiring adapters to declare themselves as sync or async, the middleware inspects return values at runtime. This keeps the StorageAdapter interface minimal and makes it easy to wrap any storage backend.

Why memoryStorage() Is the Default

When no storage option is provided, the middleware defaults to memoryStorage(). This ensures the middleware is always functional (for testing and SSR) without requiring platform APIs.

Why the Envelope Includes Version

Including version in the persisted envelope enables forward-compatible migrations. Without it, there would be no way to detect schema changes and apply transformations.

Why Partialize Exists

The partialize option allows persisting a subset of state (e.g., only user preferences, not cached API responses). This reduces storage size and avoids persisting derived or ephemeral state.

Performance Considerations

ConcernStrategyCost
Sync hydrationFor sync adapters (localStorage), hydration completes within init — no async gapO(n) where n = serialized state size
Async hydrationFor async adapters (IndexedDB), init returns immediately; state is applied when Promise resolvesNon-blocking
Write frequencyOne storage write per setState callO(n) serialization per write
PartializeReduces serialized payload by selecting a subset of stateO(k) where k = partialized keys
IndexedDB connectionLazy-opened, cached after first useO(1) after first access
Error isolationAll errors caught; store continues in-memoryGraceful degradation
Memory storageMap operations — O(1) get/set/deleteNear-zero overhead

Cross-References