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
- Read —
storage.getItem(key)returns the raw serialized string (sync or async) - Deserialize — Parse the raw string into a
PersistEnvelope<Partial<T>> - Version check — Compare
envelope.versionwith the configuredversion - Migrate — If versions differ and
migrateis defined, transform the persisted state - Merge — Combine persisted state with current store state via
merge(persisted, current) - Apply — Call
api.setState(merged)to update the store - Finish — Set
isHydrated = trueand fire registeredonHydratecallbacks
Sync vs Async Detection
The middleware detects synchronous vs asynchronous adapters via instanceof Promise on the return value:
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.
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:
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
| Adapter | Sync/Async | Platform | Use Case |
|---|---|---|---|
localStorageBackend() | Sync | Browser | General persistence across tabs |
sessionStorageBackend() | Sync | Browser | Session-scoped persistence |
cookieStorage(options) | Sync | Browser | SSR-compatible small data (auth tokens) |
indexedDBStorage(options) | Async | Browser | Large state objects (exceeds 5 MB localStorage limit) |
memoryStorage() | Sync | Universal | Testing, 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
localStorageis undefined
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:
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:
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:
interface PersistEnvelope<T> {
readonly state: T;
readonly version: number;
}The envelope serializes to JSON as:
{
"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):
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:
const PERSIST_ERROR_CODE = {
StorageReadFailed: 'StorageReadFailed',
StorageWriteFailed: 'StorageWriteFailed',
SerializationFailed: 'SerializationFailed',
DeserializationFailed: 'DeserializationFailed',
MigrationFailed: 'MigrationFailed',
} as const;| Error Code | Cause | Recovery |
|---|---|---|
StorageReadFailed | getItem threw or rejected | Hydration finishes without persisted state |
StorageWriteFailed | setItem threw or rejected | State update succeeds in memory; write skipped |
SerializationFailed | serialize threw | Write skipped; state remains in memory |
DeserializationFailed | deserialize threw | Hydration finishes without persisted state |
MigrationFailed | migrate threw or rejected | Hydration finishes without persisted state |
The error reporting function:
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:
| Method | Purpose |
|---|---|
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
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:
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:
| Variable | Type | Purpose |
|---|---|---|
api | PersistMiddlewareAPI<T> | undefined | Store API reference, set during init |
isHydrated | boolean | Whether hydration has completed |
isDestroyed | boolean | Whether the store has been destroyed |
hydrateCallbacks | Set<(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
| Concern | Strategy | Cost |
|---|---|---|
| Sync hydration | For sync adapters (localStorage), hydration completes within init — no async gap | O(n) where n = serialized state size |
| Async hydration | For async adapters (IndexedDB), init returns immediately; state is applied when Promise resolves | Non-blocking |
| Write frequency | One storage write per setState call | O(n) serialization per write |
| Partialize | Reduces serialized payload by selecting a subset of state | O(k) where k = partialized keys |
| IndexedDB connection | Lazy-opened, cached after first use | O(1) after first access |
| Error isolation | All errors caught; store continues in-memory | Graceful degradation |
| Memory storage | Map operations — O(1) get/set/delete | Near-zero overhead |
Cross-References
- Middleware Overview — where persist fits in the middleware ecosystem
- Architecture Overview — layer structure and dependency rules
- Store Design — middleware pipeline construction and hook execution order
- Core Design — signal and batch primitives
- API Reference:
@stateloom/persist— consumer-facing documentation