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:
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(partial: Partial<T> | ((state: T) => Partial<T>)): void;
getInitialState(): T;
subscribe(listener: (state: T, prevState: T) => void): () => void;
}Feature Matrix
| Package | Layer | onSet | onSubscribe | init | onDestroy | Extended API |
|---|---|---|---|---|---|---|
@stateloom/devtools | 4 | State inspection + time-travel | -- | Connect to extension | Disconnect | inspect() API |
@stateloom/persist | 4 | Persist on write | -- | Hydrate from storage | Cleanup | rehydrate(), hasHydrated(), onHydrate(), clearStorage() |
@stateloom/history | 4 | Record snapshots | -- | Store API reference | Clear stacks | undo(), redo(), clear(), canUndo, canRedo |
@stateloom/tab-sync | 4 | Broadcast to tabs | -- | Open channel | Close channel | -- |
@stateloom/telemetry | 4 | Measure + report | Track subscriptions | Fire onInit | Reset counters | setEnabled(), isEnabled() |
@stateloom/immer | 4 | -- (creator wrapper) | -- | -- | -- | produceState() |
@stateloom/server | 4 | -- (scope utility) | -- | -- | -- | fork(), dispose(), destroy() |
@stateloom/testing | 4 | -- (test utility) | -- | -- | -- | mockStore(), mockSubscribable(), etc. |
@stateloom/persist-redis | 5 | -- (storage adapter) | -- | -- | -- | redisStorage() |
Package Overview
@stateloom/devtools
Redux DevTools Extension bridge, console logger, and custom inspector API.
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.
@stateloom/persist
Declarative state persistence with pluggable storage backends and async hydration.
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:
| Adapter | Environment | Capacity | Async |
|---|---|---|---|
localStorageBackend() | Browser | ~5-10 MB | No |
sessionStorageBackend() | Browser (same tab) | ~5 MB | No |
cookieStorage(options?) | Browser + SSR | ~4 KB | No |
indexedDBStorage(options?) | Browser | Large | Yes |
memoryStorage() | Any | Memory | No |
@stateloom/history
Snapshot-based undo/redo with reactive signals.
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(); // falseCapabilities: Full state snapshots, canUndo/canRedo reactive signals, configurable maxDepth, time-travel guard for middleware chain compatibility.
@stateloom/tab-sync
Cross-tab state synchronization via BroadcastChannel.
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.
@stateloom/telemetry
Analytics hooks for state change tracking with zero application impact.
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.
@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.
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:
import { produceState } from '@stateloom/immer';
produceState(store, (draft) => {
draft.todos.push({ text: 'New item', done: false });
});@stateloom/server
Memory-bounded server scope for long-running Node.js servers.
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.
@stateloom/testing
Test utilities for @stateloom/* packages. Dev dependency only.
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().
@stateloom/persist-redis
Redis-backed storage adapter for @stateloom/persist. Bring your own Redis client.
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.
Composition Examples
Persist + Tab Sync + Devtools
State survives page reload (persist), syncs across tabs (tab-sync), and is inspectable in DevTools (devtools):
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:
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:
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... | Package | Key API |
|---|---|---|
| Inspect state in browser DevTools | @stateloom/devtools | devtools() |
| Log state changes to console | @stateloom/devtools | logger() |
| Build custom devtools | @stateloom/devtools | inspect() |
| Persist to localStorage | @stateloom/persist | persist() + localStorageBackend() |
| Persist to IndexedDB | @stateloom/persist | persist() + indexedDBStorage() |
| Persist to cookies (SSR) | @stateloom/persist | persist() + cookieStorage() |
| Persist to Redis | @stateloom/persist-redis | redisStorage() |
| Undo/redo state changes | @stateloom/history | history() → undo() / redo() |
| Sync state across tabs | @stateloom/tab-sync | broadcast() |
| Track state change performance | @stateloom/telemetry | telemetry() |
| Use mutable draft syntax | @stateloom/immer | immer() / produceState() |
| SSR scope isolation | @stateloom/server | createServerScope() |
| Test reactive state | @stateloom/testing | createTestScope() / mockStore() |
Middleware Ordering
Middleware order matters. The first middleware in the array is the outermost wrapper:
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:
- Observational (logger, telemetry) -- wraps everything for complete visibility
- Devtools -- captures state for time-travel
- History -- records snapshots before the update
- 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.