Skip to content

Middleware & Ecosystem

Cross-cutting concerns implemented as composable middleware and utilities. Each package is independent and depends only on @stateloom/core.

Middleware Pipeline

Middleware wraps the setState pipeline. Each middleware's onSet hook wraps the next, forming a chain. The first middleware in the array is the outermost wrapper -- it sees the call first and returns last.

Middleware Interface

All middleware implements this interface structurally:

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(partial: Partial<T> | ((state: T) => Partial<T>)): void;
  getInitialState(): T;
  subscribe(listener: (state: T, prevState: T) => void): () => void;
}

Feature Matrix

PackageLayeronSetonSubscribeinitonDestroyExtended API
@stateloom/devtools4State inspection + time-travel--Connect to extensionDisconnectinspect() API
@stateloom/persist4Persist on write--Hydrate from storageCleanuprehydrate(), hasHydrated(), onHydrate(), clearStorage()
@stateloom/history4Record snapshots--Store API referenceClear stacksundo(), redo(), clear(), canUndo, canRedo
@stateloom/tab-sync4Broadcast to tabs--Open channelClose channel--
@stateloom/telemetry4Measure + reportTrack subscriptionsFire onInitReset counterssetEnabled(), isEnabled()
@stateloom/immer4-- (creator wrapper)------produceState()
@stateloom/server4-- (scope utility)------fork(), dispose(), destroy()
@stateloom/testing4-- (test utility)------mockStore(), mockSubscribable(), etc.
@stateloom/persist-redis5-- (storage adapter)------redisStorage()

Package Overview

@stateloom/devtools

Redux DevTools Extension bridge, console logger, and custom inspector API.

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

const store = createStore(
  (set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }),
  {
    middleware: [devtools({ name: 'Counter' }), logger({ diff: true })],
  },
);

Capabilities: Time-travel debugging, action name inference, console logging with diffs, custom inspect() API for analytics.

Full documentation →


@stateloom/persist

Declarative state persistence with pluggable storage backends and async hydration.

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

const store = createStore(
  (set) => ({
    theme: 'light' as 'light' | 'dark',
    locale: 'en',
    setTheme: (theme: 'light' | 'dark') => set({ theme }),
  }),
  {
    middleware: [
      persist({
        key: 'user-preferences',
        storage: localStorageBackend(),
        partialize: (state) => ({ theme: state.theme, locale: state.locale }),
      }),
    ],
  },
);

Built-in storage adapters:

AdapterEnvironmentCapacityAsync
localStorageBackend()Browser~5-10 MBNo
sessionStorageBackend()Browser (same tab)~5 MBNo
cookieStorage(options?)Browser + SSR~4 KBNo
indexedDBStorage(options?)BrowserLargeYes
memoryStorage()AnyMemoryNo

Full documentation →


@stateloom/history

Snapshot-based undo/redo with reactive signals.

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

const h = history<{ count: number; increment: () => void }>({ maxDepth: 50 });
const store = createStore(
  (set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }),
  { middleware: [h] },
);

store.getState().increment(); // count: 1
h.undo(); // count: 0
h.redo(); // count: 1
h.canUndo.get(); // true
h.canRedo.get(); // false

Capabilities: Full state snapshots, canUndo/canRedo reactive signals, configurable maxDepth, time-travel guard for middleware chain compatibility.

Full documentation →


@stateloom/tab-sync

Cross-tab state synchronization via BroadcastChannel.

typescript
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';

const store = createStore(
  (set) => ({
    theme: 'light' as 'light' | 'dark',
    locale: 'en',
    count: 0,
    setTheme: (t: 'light' | 'dark') => set({ theme: t }),
  }),
  {
    middleware: [
      broadcast({
        channel: 'app-state',
        filter: (state) => ({ theme: state.theme, locale: state.locale }),
        conflictResolution: 'timestamp',
      }),
    ],
  },
);

Capabilities: Field-level filtering, conflict resolution (last-write-wins, timestamp, custom function), loop prevention, graceful SSR degradation.

Full documentation →


@stateloom/telemetry

Analytics hooks for state change tracking with zero application impact.

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

const t = telemetry<{ count: number }>({
  onStateChange: (meta) => {
    analytics.track('state_change', {
      duration: meta.durationMs,
      changeCount: meta.changeCount,
    });
  },
  onError: (ctx) => errorReporter.capture(ctx.error),
});

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

// Disable at runtime
t.setEnabled(false);

Capabilities: State change tracking with durationMs, subscription counting, error isolation (callbacks never crash the app), runtime enable/disable, performance.now() precision.

Full documentation →


@stateloom/immer

Immer integration for mutable draft syntax in store set() calls.

Not a Middleware

immer is a creator wrapper, not a middleware. It wraps the set function inside the creator, not the middleware pipeline.

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

const store = createStore(
  immer((set) => ({
    todos: [] as Array<{ text: string; done: boolean }>,
    addTodo: (text: string) =>
      set((draft) => {
        draft.todos.push({ text, done: false });
      }),
    toggleTodo: (index: number) =>
      set((draft) => {
        draft.todos[index].done = !draft.todos[index].done;
      }),
    reset: () => set({ todos: [] }), // plain partial — zero Immer overhead
  })),
);

Use produceState for draft syntax outside the creator:

typescript
import { produceState } from '@stateloom/immer';

produceState(store, (draft) => {
  draft.todos.push({ text: 'New item', done: false });
});

Full documentation →


@stateloom/server

Memory-bounded server scope for long-running Node.js servers.

typescript
import { createServerScope } from '@stateloom/server';
import { signal, runInScope } from '@stateloom/core';

const server = createServerScope({
  ttl: 60_000,
  maxEntries: 10_000,
});

const userId = signal<string | null>(null);

// Per-request handler
app.get('/api/data', async (req, res) => {
  const reqScope = server.fork();
  runInScope(reqScope, () => {
    reqScope.set(userId, req.user.id);
  });

  const data = reqScope.serialize();
  server.dispose(reqScope.id);
  res.json(data);
});

Capabilities: LRU eviction + TTL expiration, lazy sweep (no background timers), monotonic scope IDs, onEvict callback for metrics.

Full documentation →


@stateloom/testing

Test utilities for @stateloom/* packages. Dev dependency only.

typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { signal } from '@stateloom/core';
import { createTestScope, mockSubscribable, collectValues } from '@stateloom/testing';

describe('my feature', () => {
  const testScope = createTestScope();

  beforeEach(() => {
    testScope.reset();
  });

  it('tracks scoped state', () => {
    const count = signal(0);
    testScope.set(count, 42);
    expect(testScope.get(count)).toBe(42);
  });

  it('collects emitted values', () => {
    const mock = mockSubscribable(0);
    const values = collectValues(mock);
    mock.emit(1);
    mock.emit(2);
    expect([...values]).toEqual([1, 2]);
    values.unsubscribe();
  });
});

Utilities: createTestScope(), mockSubscribable(), mockStore(), collectValues(), flushEffects(), waitForUpdate().

Full documentation →


@stateloom/persist-redis

Redis-backed storage adapter for @stateloom/persist. Bring your own Redis client.

typescript
import Redis from 'ioredis';
import { createStore } from '@stateloom/store';
import { persist } from '@stateloom/persist';
import { redisStorage } from '@stateloom/persist-redis';

const store = createStore(
  (set) => ({
    theme: 'light' as 'light' | 'dark',
    setTheme: (t: 'light' | 'dark') => set({ theme: t }),
  }),
  {
    middleware: [
      persist({
        key: 'user-prefs',
        storage: redisStorage({
          client: new Redis(),
          prefix: 'myapp:',
          ttl: 3600,
        }),
      }),
    ],
  },
);

Capabilities: BYO Redis client (ioredis, node-redis, Upstash), key prefixing, TTL support, zero runtime Redis dependencies.

Full documentation →


Composition Examples

Persist + Tab Sync + Devtools

State survives page reload (persist), syncs across tabs (tab-sync), and is inspectable in DevTools (devtools):

typescript
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist, localStorageBackend } from '@stateloom/persist';
import { broadcast } from '@stateloom/tab-sync';

interface AppState {
  theme: 'light' | 'dark';
  locale: string;
  setTheme: (t: 'light' | 'dark') => void;
  setLocale: (l: string) => void;
}

const isDev = import.meta.env.DEV;

const store = createStore<AppState>(
  (set) => ({
    theme: 'light',
    locale: 'en',
    setTheme: (t) => set({ theme: t }),
    setLocale: (l) => set({ locale: l }),
  }),
  {
    middleware: [
      devtools({ name: 'App', enabled: isDev }),
      persist({
        key: 'user-prefs',
        storage: localStorageBackend(),
        partialize: (s) => ({ theme: s.theme, locale: s.locale }),
      }),
      broadcast({
        channel: 'user-prefs',
        filter: (s) => ({ theme: s.theme, locale: s.locale }),
      }),
    ],
  },
);

History + Persist

Undo/redo changes are persisted automatically since undo/redo flows through the middleware chain:

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

const h = history<{ content: string }>({ maxDepth: 200 });
const persistMw = persist<{ content: string }>({
  key: 'editor',
  storage: localStorageBackend(),
});

const store = createStore(
  (set) => ({
    content: '',
    setContent: (c: string) => set({ content: c }),
  }),
  { middleware: [h, persistMw] },
);

// Undo/redo → persist sees the state change → writes to storage
h.undo();

Immer + History + Telemetry

Draft syntax for mutations, undo/redo, and performance monitoring:

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

interface EditorState {
  items: Array<{ id: number; text: string }>;
  nextId: number;
  addItem: (text: string) => void;
  removeItem: (id: number) => void;
}

const h = history<EditorState>();
const t = telemetry<EditorState>({
  onStateChange: (meta) => {
    if (meta.durationMs > 16) {
      console.warn(`Slow update: ${meta.durationMs}ms`);
    }
  },
});

const store = createStore<EditorState>(
  immer((set) => ({
    items: [],
    nextId: 1,
    addItem: (text) =>
      set((draft) => {
        draft.items.push({ id: draft.nextId, text });
        draft.nextId++;
      }),
    removeItem: (id) =>
      set((draft) => {
        const idx = draft.items.findIndex((i) => i.id === id);
        if (idx !== -1) draft.items.splice(idx, 1);
      }),
  })),
  { middleware: [t, h] },
);

Decision Guide

Which Middleware Do I Need?

Quick Reference

I want to...PackageKey API
Inspect state in browser DevTools@stateloom/devtoolsdevtools()
Log state changes to console@stateloom/devtoolslogger()
Build custom devtools@stateloom/devtoolsinspect()
Persist to localStorage@stateloom/persistpersist() + localStorageBackend()
Persist to IndexedDB@stateloom/persistpersist() + indexedDBStorage()
Persist to cookies (SSR)@stateloom/persistpersist() + cookieStorage()
Persist to Redis@stateloom/persist-redisredisStorage()
Undo/redo state changes@stateloom/historyhistory()undo() / redo()
Sync state across tabs@stateloom/tab-syncbroadcast()
Track state change performance@stateloom/telemetrytelemetry()
Use mutable draft syntax@stateloom/immerimmer() / produceState()
SSR scope isolation@stateloom/servercreateServerScope()
Test reactive state@stateloom/testingcreateTestScope() / mockStore()

Middleware Ordering

Middleware order matters. The first middleware in the array is the outermost wrapper:

typescript
const store = createStore(creator, {
  middleware: [
    logger(), // sees the call first, logs after everything else
    devtools(), // captures state for time-travel
    history(), // records snapshots before the update
    persist(), // writes to storage after the update
  ],
});

Recommended order:

  1. Observational (logger, telemetry) -- wraps everything for complete visibility
  2. Devtools -- captures state for time-travel
  3. History -- records snapshots before the update
  4. Persist / Tab-sync -- writes after the update propagates

TIP

immer is a creator wrapper, not middleware. It wraps the set function before the middleware chain, so it works regardless of middleware order.