Devtools Design
Low-level design for @stateloom/devtools — the Redux DevTools Extension bridge and console logging middleware. Covers the connection architecture, time-travel guard mechanism, action name derivation, inspect API, and logger utility.
Overview
The @stateloom/devtools package provides three capabilities for store debugging:
devtools()— Middleware that bridges to the Redux DevTools Extension for state inspection and time-travel debugginglogger()— Middleware that logs state transitions to the console with timestamps, action names, and optional diffsinspect()— A global callback registry that receives events from all devtools-instrumented stores
All three are SSR-safe and degrade gracefully when the extension or console is unavailable.
Architecture
Redux DevTools Bridge
The devtools() middleware connects to the Redux DevTools Extension via the __REDUX_DEVTOOLS_EXTENSION__ global. The connection is bidirectional: the middleware sends state updates to the extension, and the extension sends time-travel commands back to the middleware.
Connection Flow
The init hook establishes the bridge in three steps:
init(middlewareApi: MiddlewareAPI<T>): void {
api = middlewareApi;
if (!enabled) return;
const ext = getExtension();
if (ext === undefined) return;
connection = ext.connect({ name: storeName });
connection.init(api.getState());
emitInspectEvent({
type: InspectEventType.INIT,
storeName,
state: api.getState(),
timestamp: Date.now(),
});
const result = connection.subscribe(handleMessage);
// Handle both function and { unsubscribe() } return types
if (typeof result === 'function') {
unsubFromExtension = result;
} else {
const sub = result;
unsubFromExtension = () => { sub.unsubscribe(); };
}
}The extension's subscribe method can return either a function or an object with an unsubscribe method. The middleware normalizes both forms into a stored unsubscribe function.
Time-Travel Guard
During time-travel operations (JUMP_TO_STATE, RESET, ROLLBACK, IMPORT_STATE), the middleware sets an isTimeTraveling flag. This prevents the onSet hook from re-sending the state change back to the extension, which would create an infinite loop:
The guard is wrapped in a try/finally block to ensure it is always reset, even if setState throws:
isTimeTraveling = true;
try {
api.setState(() => parsed);
} finally {
isTimeTraveling = false;
}Supported DevTools Commands
The handleMessage function dispatches on message.payload.type:
| Command | Behavior |
|---|---|
JUMP_TO_STATE / JUMP_TO_ACTION | Parse JSON state from message.state or payload.state, apply via setState with time-travel guard |
RESET | Restore getInitialState(), re-initialize connection with initial state |
COMMIT | Re-initialize connection with current state (new baseline) |
ROLLBACK | Parse JSON state, apply via setState, re-initialize connection with that state |
IMPORT_STATE | Extract the last computed state from payload.nextLiftedState.computedStates, apply via setState |
All commands filter on message.type === 'DISPATCH' — non-dispatch messages are ignored. JSON parse errors are caught silently to handle malformed state from the extension.
Action Name Derivation
The onSet hook derives a human-readable action name for each state update sent to the extension:
const actionLabel =
actionNameFn !== undefined
? actionNameFn(partial as Record<string, unknown>)
: deriveActionName(partial as Record<string, unknown>);
connection?.send({ type: actionLabel }, nextState);The default deriveActionName inspects the keys of the partial state object:
function deriveActionName(partial: Record<string, unknown>): string {
const keys = Object.keys(partial);
if (keys.length === 0) return 'setState';
keys.sort();
return `set:${keys.join(',')}`;
}This produces labels like set:count, set:count,name, or setState for empty partials. Keys are sorted for stable, deterministic names. A custom actionName function can be provided via DevtoolsOptions to override this behavior.
Graceful Degradation
The middleware is SSR-safe. The getExtension() function checks globalThis['__REDUX_DEVTOOLS_EXTENSION__'] and returns undefined when the extension is not installed:
function getExtension(): ReduxDevtoolsExtension | undefined {
const g = globalThis as Record<string, unknown>;
const ext = g['__REDUX_DEVTOOLS_EXTENSION__'];
if (ext === undefined || ext === null) return undefined;
return ext as ReduxDevtoolsExtension;
}When the extension is unavailable:
initbecomes a no-op (no connection established)onSetsends toconnection?.send()— the optional chain makes it a no-op- No errors are thrown
Setting enabled: false also disables the middleware entirely — init returns immediately, and onSet skips after calling next(partial).
Inspect API
The inspect() function provides a framework-agnostic callback registry for building custom devtools:
Event Types
The InspectEventType constant defines three event types:
| Type | Emitted When | Fields |
|---|---|---|
init | devtools.init() connects | storeName, state, timestamp |
set | devtools.onSet() sends an update | storeName, action, state, prevState, timestamp |
destroy | devtools.onDestroy() disconnects | storeName, state, timestamp |
Error Isolation
Inspector callbacks are invoked inside a try/catch — a failing callback never blocks other inspectors or the store:
function emitInspectEvent(event: InspectEvent): void {
if (inspectors.size === 0) return;
for (const cb of inspectors) {
try {
cb(event);
} catch {
// Inspector callback errors must never block other inspectors or the store.
}
}
}The inspectors.size === 0 check on the hot path avoids unnecessary iteration when no inspectors are registered.
Logger Utility
The logger() middleware provides console-based debugging without requiring the Redux DevTools Extension:
Shallow Diff
When diff: true is set, the logger computes a shallow diff of changed keys:
function shallowDiff(
prev: Record<string, unknown>,
next: Record<string, unknown>,
): Record<string, { prev: unknown; next: unknown }> {
const diff: Record<string, { prev: unknown; next: unknown }> = {};
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of allKeys) {
if (prev[key] !== next[key]) {
diff[key] = { prev: prev[key], next: next[key] };
}
}
return diff;
}This uses strict reference equality (!==), so only keys whose values changed identity are included.
Dependency Injection
The logger option accepts any object implementing the LoggerInterface (log, group, groupCollapsed, groupEnd). This enables testing without relying on console:
const mockLogger: LoggerInterface = {
log: vi.fn(),
group: vi.fn(),
groupCollapsed: vi.fn(),
groupEnd: vi.fn(),
};
const l = logger({ logger: mockLogger });Closed-Over State
Both devtools() and logger() use closures over mutable state rather than class instances:
| Variable | devtools() | logger() |
|---|---|---|
connection | DevtoolsConnection | undefined | N/A |
unsubFromExtension | (() => void) | undefined | N/A |
isTimeTraveling | boolean | N/A |
api | MiddlewareAPI<T> | undefined | N/A |
enabled | boolean (from options) | boolean (from options) |
This mirrors the store's own closure pattern — the middleware is a plain object whose methods close over shared mutable state.
Design Decisions
Why Structural Typing for MiddlewareAPI
The @stateloom/devtools package declares its own MiddlewareAPI<T> interface that structurally matches the store's version. This keeps the package in the middleware layer (depends only on @stateloom/core) without a runtime import of @stateloom/store.
Why try/finally for the Time-Travel Guard
Using try/finally ensures isTimeTraveling is always reset, even if api.setState() throws. Without this, a single error during time-travel would permanently disable the onSet hook from sending updates to the extension.
Why the Logger Calls next() Between Group Entries
The logger captures prevState before next(partial) and nextState after. This ensures the logged states reflect the actual before/after of the middleware chain execution, not just the partial that was requested.
Why Inspect Uses a Module-Level Singleton
The inspect registry is a module-level Set rather than being scoped to individual middleware instances. This allows consumers to observe all devtools-instrumented stores from a single callback, which is the common use case for custom devtools panels and analytics.
Why deriveActionName Sorts Keys
Sorting keys produces deterministic action names regardless of property insertion order. This ensures that { count: 1, name: 'a' } and { name: 'a', count: 1 } both produce set:count,name.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Extension unavailable | getExtension() returns undefined; all operations become no-ops via optional chaining | O(1) |
enabled: false | onSet calls next(partial) and returns immediately | O(1) — near-zero overhead |
| Action name derivation | Object.keys + sort on each onSet | O(k log k) where k = number of changed keys |
| JSON serialization | Only for time-travel operations (parse incoming state) | O(n) where n = state size, only on user action |
| Inspect broadcast | Early return when inspectors.size === 0 | O(0) when unused, O(i) when active |
| Logger diff | Shallow key comparison only when diff: true | O(k) where k = total state keys |
| Console grouping | groupCollapsed by default — minimizes visual noise | No runtime cost |
Cross-References
- Middleware Overview — where devtools 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/devtools— consumer-facing documentation