Skip to content

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:

typescript
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;
}
HookWhen CalledPurpose
initAfter store is fully constructedOne-time setup (connect to devtools, read from storage)
onSetOn each setState callIntercept writes (log, persist, record history)
onGetReserved for proxy paradigmsIntercept reads
onSubscribeWhen a listener is registeredWrap/transform listener callbacks
onDestroyWhen store is destroyedCleanup (disconnect, clear caches)

The MiddlewareAPI<T> provides read/write access to the store:

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

typescript
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

HookDirectionEffect
onSetOutermost first (before/after pattern)First middleware sees the call first and returns last
onSubscribeOutermost firstFirst middleware wraps last
initArray order (first to last)Sequential initialization
onDestroyArray 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:

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

  1. Chain before init: The middleware chain is built before init is called, so middleware can call setState during init (e.g., persist rehydrating from storage)
  2. Init after store is operational: init is called after the store is fully constructed — the signal exists, listeners are connected, and the middleware chain is active
  3. Destroy before clear: onDestroy is 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

NeedPackageKey Feature
State inspection in browser@stateloom/devtoolsRedux DevTools Extension bridge with time-travel
Persist to localStorage/sessionStorage@stateloom/persistBuilt-in browser storage adapters with versioning and migration
Persist to Redis@stateloom/persist + @stateloom/persist-redisHTTP (Upstash-compatible) + TCP Redis adapters
Undo/redo@stateloom/historySnapshot-based with reactive canUndo/canRedo signals
Cross-tab synchronization@stateloom/tab-syncBroadcastChannel with loop prevention and field filtering
Mutable update syntax@stateloom/immerImmer produce() integration for set()
Analytics/metrics@stateloom/telemetryonStateChange callbacks with duration measurement

Middleware Independence

All middleware packages are independent of each other. They can be composed in any combination:

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

Two packages in the ecosystem layer are not middleware but work alongside middleware:

PackageRole
@stateloom/serverServer-side scope management (TTL-based, LRU-evicting) — not a Middleware<T> implementor
@stateloom/testingTest utilities (createTestScope, mockStore) — not a Middleware<T> implementor

Performance Considerations

ConcernStrategyCost
Middleware overheadOne closure call per middleware per setStateO(m) per write, m = middleware count
History memoryBounded by maxDepth (default: 100); oldest entries evictedO(d) where d = maxDepth
Persist writesOne storage write per setState; async adapters don't blockO(1) sync, O(1) async (non-blocking)
Devtools serializationJSON.parse/stringify only for time-travel operationsO(n) where n = state size, only on time-travel
Structural typingZero runtime cost — resolved at compile time0
Error isolationAll storage/serialization errors caught; store continues in-memoryGraceful degradation
Tab-sync messagesOne BroadcastChannel postMessage per setState; loop prevention avoids echoO(1) per write
Telemetry measurementperformance.now() for duration; configurable sampling rateO(1) per write when enabled

Cross-References

Individual Middleware Design Docs

Architecture Docs