Middleware Overview
Cross-cutting patterns shared by all middleware packages — the Middleware<T> interface, pipeline composition model, hook execution order, structural typing, lifecycle management, and composition patterns. Per-middleware implementation details live in individual middleware design docs.
Overview
Middleware adds cross-cutting concerns (persistence, devtools, undo/redo, synchronization, analytics) to stores without modifying the store implementation. Each middleware implements a set of lifecycle hooks that intercept store operations. Middleware packages depend only on @stateloom/core as a peer dependency — they use structural typing to match the store's Middleware<T> interface without importing @stateloom/store.
The middleware layer sits between the store paradigm (Layer 2) and platform backends (Layer 5). Middleware packages are independent of each other — they can be composed in any combination without conflicts.
Middleware Interface
The Middleware<T> interface defines five optional hooks:
interface Middleware<T> {
readonly name: string;
readonly init?: (api: MiddlewareAPI<T>) => void;
readonly onSet?: (api: MiddlewareAPI<T>, next: SetFn<T>, partial: Partial<T>) => void;
readonly onGet?: (api: MiddlewareAPI<T>, key: keyof T) => void;
readonly onSubscribe?: (api: MiddlewareAPI<T>, listener: Listener<T>) => Listener<T>;
readonly onDestroy?: (api: MiddlewareAPI<T>) => void;
}| Hook | When Called | Purpose |
|---|---|---|
init | After store is fully constructed | One-time setup (connect to devtools, read from storage) |
onSet | On each setState call | Intercept writes (log, persist, record history) |
onGet | Reserved for proxy paradigms | Intercept reads |
onSubscribe | When a listener is registered | Wrap/transform listener callbacks |
onDestroy | When store is destroyed | Cleanup (disconnect, clear caches) |
The MiddlewareAPI<T> provides read/write access to the store:
interface MiddlewareAPI<T> {
getState(): T;
setState: SetStateFn<T>;
getInitialState(): T;
subscribe(listener: Listener<T>): () => void;
}Middleware receives this API via the init hook and through each onSet call. The API is the same for all middleware — it does not vary by middleware type or position in the chain.
Composition Model
Middleware is composed into a pipeline around the raw setState function. The first middleware in the array is the outermost wrapper.
Pipeline Construction
The chain is built bottom-up (last middleware wraps first, then second-to-last wraps that, etc.):
function buildSetChain<T>(rawSet, middlewares, api): SetFn<T> {
let chain = rawSet;
for (let i = middlewares.length - 1; i >= 0; i--) {
if (middlewares[i].onSet) {
const nextSet = chain;
const onSet = middlewares[i].onSet;
chain = (partial) => {
onSet(api, nextSet, partial);
};
}
}
return chain;
}Execution Flow
Each onSet receives next — calling it passes the update to the next middleware. Not calling next blocks the update entirely, which is useful for:
- Validation: reject invalid state transitions
- Debouncing: delay writes
- Time-travel: replace partial with a full snapshot
Hook Wrapping Direction
| Hook | Direction | Effect |
|---|---|---|
onSet | Outermost first (before/after pattern) | First middleware sees the call first and returns last |
onSubscribe | Outermost first | First middleware wraps last |
init | Array order (first to last) | Sequential initialization |
onDestroy | Array order (first to last) | Sequential cleanup |
The onSet before/after pattern means a middleware can execute logic both before and after the rest of the chain by placing code before and after the next() call. This is essential for logging (log prev before, log next after), devtools (capture prevState before, send to extension after), and persist (pass through first, then persist).
Structural Typing
Middleware packages avoid importing @stateloom/store at runtime. Instead, they declare their own interfaces that structurally match the store's types:
// In @stateloom/persist/src/types.ts
interface PersistMiddlewareAPI<T> {
getState(): T;
setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
getInitialState(): T;
subscribe(listener: PersistListener<T>): () => void;
}TypeScript's structural typing makes this assignable to the store's MiddlewareAPI<T> without an explicit extends relationship. This keeps middleware in Layer 4 (depends only on core) rather than Layer 2 (paradigm layer), preserving the layered architecture.
The runtime cost is zero — structural typing is resolved entirely at compile time.
Middleware Lifecycle
Key lifecycle ordering:
- Chain before init: The middleware chain is built before
initis called, so middleware can callsetStateduringinit(e.g., persist rehydrating from storage) - Init after store is operational:
initis called after the store is fully constructed — the signal exists, listeners are connected, and the middleware chain is active - Destroy before clear:
onDestroyis called before listeners are cleared, so middleware can still read state and perform cleanup operations
Lifecycle State Diagram
Each middleware instance transitions through well-defined states:
Composition Patterns
Middleware onSet hooks fall into three categories based on how they interact with the next() call.
Pattern: Filtering (Validation)
A middleware that conditionally blocks state transitions by not calling next():
Use cases: schema validation, authorization checks, rate limiting.
Pattern: Transformation (Normalization)
A middleware that modifies the partial before passing it through:
Use cases: input normalization, default value injection, field coercion.
Pattern: Side-Effect (Logging/Telemetry)
A middleware that observes without modifying the update:
Use cases: logging, analytics, performance measurement, debugging.
All three patterns can be combined in the same middleware (e.g., validate, normalize, then log), and multiple middleware of different patterns compose naturally in the pipeline.
When to Use Each Middleware
| Need | Package | Key Feature |
|---|---|---|
| State inspection in browser | @stateloom/devtools | Redux DevTools Extension bridge with time-travel |
| Persist to localStorage/sessionStorage | @stateloom/persist | Built-in browser storage adapters with versioning and migration |
| Persist to Redis | @stateloom/persist + @stateloom/persist-redis | HTTP (Upstash-compatible) + TCP Redis adapters |
| Undo/redo | @stateloom/history | Snapshot-based with reactive canUndo/canRedo signals |
| Cross-tab synchronization | @stateloom/tab-sync | BroadcastChannel with loop prevention and field filtering |
| Mutable update syntax | @stateloom/immer | Immer produce() integration for set() |
| Analytics/metrics | @stateloom/telemetry | onStateChange callbacks with duration measurement |
Middleware Independence
All middleware packages are independent of each other. They can be composed in any combination:
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';
import { history } from '@stateloom/history';
const store = createStore(creator, {
middleware: [devtools({ name: 'MyStore' }), persist({ key: 'my-store' }), history()],
});The order matters: the first middleware is outermost. In this example, devtools sees every state change (including persist rehydration and history undo/redo), persist writes after every successful update, and history records before persist writes.
Related Non-Middleware Packages
Two packages in the ecosystem layer are not middleware but work alongside middleware:
| Package | Role |
|---|---|
@stateloom/server | Server-side scope management (TTL-based, LRU-evicting) — not a Middleware<T> implementor |
@stateloom/testing | Test utilities (createTestScope, mockStore) — not a Middleware<T> implementor |
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Middleware overhead | One closure call per middleware per setState | O(m) per write, m = middleware count |
| History memory | Bounded by maxDepth (default: 100); oldest entries evicted | O(d) where d = maxDepth |
| Persist writes | One storage write per setState; async adapters don't block | O(1) sync, O(1) async (non-blocking) |
| Devtools serialization | JSON.parse/stringify only for time-travel operations | O(n) where n = state size, only on time-travel |
| Structural typing | Zero runtime cost — resolved at compile time | 0 |
| Error isolation | All storage/serialization errors caught; store continues in-memory | Graceful degradation |
| Tab-sync messages | One BroadcastChannel postMessage per setState; loop prevention avoids echo | O(1) per write |
| Telemetry measurement | performance.now() for duration; configurable sampling rate | O(1) per write when enabled |
Cross-References
Individual Middleware Design Docs
- Devtools Design — Redux DevTools bridge, time-travel guard, action name derivation
- Persist Design — hydration flow, storage adapters, versioning, migration
- History Design — snapshot stacks, undo/redo mechanics, reactive signals
- Immer Design — Immer
produce()integration withset() - Telemetry Design — state change callbacks, duration measurement
- Tab-Sync Design — BroadcastChannel, loop prevention, field filtering
Related Package Design Docs
- Server Design — server-side scope management (non-middleware)
- Persist-Redis Design — Redis storage adapter (platform backend)
Architecture Docs
- Architecture Overview — where middleware fits in the layer structure
- Store Design — how the middleware pipeline is constructed and invoked
- Core Design —
signalandbatchprimitives that middleware uses - Layer Scoping — boundary rules (middleware depends only on core)
- Design Philosophy — composition-over-configuration principle