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