Skip to content

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:

  1. devtools() — Middleware that bridges to the Redux DevTools Extension for state inspection and time-travel debugging
  2. logger() — Middleware that logs state transitions to the console with timestamps, action names, and optional diffs
  3. inspect() — 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:

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

typescript
isTimeTraveling = true;
try {
  api.setState(() => parsed);
} finally {
  isTimeTraveling = false;
}

Supported DevTools Commands ​

The handleMessage function dispatches on message.payload.type:

CommandBehavior
JUMP_TO_STATE / JUMP_TO_ACTIONParse JSON state from message.state or payload.state, apply via setState with time-travel guard
RESETRestore getInitialState(), re-initialize connection with initial state
COMMITRe-initialize connection with current state (new baseline)
ROLLBACKParse JSON state, apply via setState, re-initialize connection with that state
IMPORT_STATEExtract 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:

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

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

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

  • init becomes a no-op (no connection established)
  • onSet sends to connection?.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:

TypeEmitted WhenFields
initdevtools.init() connectsstoreName, state, timestamp
setdevtools.onSet() sends an updatestoreName, action, state, prevState, timestamp
destroydevtools.onDestroy() disconnectsstoreName, state, timestamp

Error Isolation ​

Inspector callbacks are invoked inside a try/catch — a failing callback never blocks other inspectors or the store:

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

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

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

Variabledevtools()logger()
connectionDevtoolsConnection | undefinedN/A
unsubFromExtension(() => void) | undefinedN/A
isTimeTravelingbooleanN/A
apiMiddlewareAPI<T> | undefinedN/A
enabledboolean (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 ​

ConcernStrategyCost
Extension unavailablegetExtension() returns undefined; all operations become no-ops via optional chainingO(1)
enabled: falseonSet calls next(partial) and returns immediatelyO(1) — near-zero overhead
Action name derivationObject.keys + sort on each onSetO(k log k) where k = number of changed keys
JSON serializationOnly for time-travel operations (parse incoming state)O(n) where n = state size, only on user action
Inspect broadcastEarly return when inspectors.size === 0O(0) when unused, O(i) when active
Logger diffShallow key comparison only when diff: trueO(k) where k = total state keys
Console groupinggroupCollapsed by default — minimizes visual noiseNo runtime cost

Cross-References ​