Skip to content

@stateloom/devtools

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

Install

bash
pnpm add @stateloom/devtools
bash
npm install @stateloom/devtools
bash
yarn add @stateloom/devtools

Size: ~0.8 KB gzipped

Overview

The package provides three capabilities: (1) devtools middleware connecting to the Redux DevTools Extension for time-travel debugging, (2) logger middleware for console output, and (3) inspect API for custom tooling.

Quick Start

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 })] },
);

Guide

Redux DevTools Integration

Add the devtools middleware to connect your store to the Redux DevTools Extension. Open the extension panel to see state changes, action names, and time-travel controls.

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

const store = createStore(
  (set) => ({
    count: 0,
    name: 'Alice',
    increment: () => set((s) => ({ count: s.count + 1 })),
    rename: (name: string) => set({ name }),
  }),
  { middleware: [devtools({ name: 'MyStore' })] },
);

store.getState().increment();
// DevTools shows: action "set:count", state { count: 1, name: 'Alice' }

Action names are inferred automatically from the keys in the partial update (set:count, set:count,name). Override with a custom function:

typescript
devtools({
  name: 'MyStore',
  actionName: (partial) => {
    if ('count' in partial) return 'INCREMENT';
    return 'UPDATE';
  },
});

Time-Travel Debugging

The devtools middleware supports the full Redux DevTools time-travel protocol:

  • Jump to state/action -- click any entry in the history to restore that state
  • Reset -- restore to the initial state
  • Commit -- make the current state the new base
  • Rollback -- revert to the last committed state
  • Import/Export -- save and restore the full state history

Console Logger

Add the logger middleware for development debugging without the DevTools Extension:

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

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

store.getState().increment();
// Console output:
//   > Counter @ 14:23:15.123 set:count
//     prev state { count: 0 }
//     action     set:count
//     next state { count: 1 }
//     diff       { count: { prev: 0, next: 1 } }

Custom Inspector API

Use inspect to register a global callback that receives events from all stores using the devtools middleware:

typescript
import { inspect } from '@stateloom/devtools';

const unsub = inspect((event) => {
  if (event.type === 'set') {
    myAnalytics.track('state_change', {
      store: event.storeName,
      action: event.action,
    });
  }
});

// Later: stop inspecting
unsub();

Disabling in Production

Set enabled: false to disable the middleware entirely with zero overhead:

typescript
devtools({
  name: 'MyStore',
  enabled: import.meta.env.DEV,
});

logger({
  enabled: import.meta.env.DEV,
});

API Reference

devtools<T>(options?: DevtoolsOptions): DevtoolsMiddleware<T>

Create a Redux DevTools Extension bridge middleware.

Parameters:

ParameterTypeDescriptionDefault
optionsDevtoolsOptionsConfiguration options.undefined
options.namestringDisplay name in the DevTools panel."StateLoom Store"
options.enabledbooleanWhether the middleware is active.true
options.actionName(partial: Record<string, unknown>) => stringCustom action name derivation.Auto-inferred

Returns: DevtoolsMiddleware<T> -- a middleware instance compatible with createStore.

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

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

Key behaviors:

  • Gracefully degrades to no-op when window.__REDUX_DEVTOOLS_EXTENSION__ is unavailable (SSR-safe)
  • Time-travel operations (JUMP_TO_STATE, RESET, COMMIT, ROLLBACK, IMPORT_STATE) are handled automatically
  • During time-travel, the isTimeTraveling guard prevents re-sending state changes to the extension
  • Emits inspect events for init, set, and destroy lifecycle hooks
  • Set enabled: false to disable with zero overhead

See also: logger(), inspect()


logger<T>(options?: LoggerOptions): LoggerMiddleware<T>

Create a console logging middleware.

Parameters:

ParameterTypeDescriptionDefault
optionsLoggerOptionsConfiguration options.undefined
options.namestringDisplay name in console output."StateLoom"
options.collapsedbooleanUse collapsed console groups.true
options.diffbooleanShow shallow diff of changed keys.false
options.enabledbooleanWhether the logger is active.true
options.loggerLoggerInterfaceCustom console-like logger.console
options.actionName(partial: Record<string, unknown>) => stringCustom action name derivation.Auto-inferred

Returns: LoggerMiddleware<T> -- a middleware instance compatible with createStore.

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

const store = createStore(
  (set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })) }),
  { middleware: [logger({ diff: true, collapsed: false })] },
);

Key behaviors:

  • Uses console.groupCollapsed by default for clean output
  • Logs timestamps in HH:MM:SS.mmm format
  • The diff option shows a shallow diff of changed keys (top-level only)
  • When enabled: false, becomes a pass-through that calls next(partial) with zero overhead
  • The logger option accepts any object with log, group, groupCollapsed, and groupEnd -- useful for testing

See also: devtools()


inspect(callback: InspectCallback): () => void

Register a global inspector callback for custom devtools.

Parameters:

ParameterTypeDescriptionDefault
callbackInspectCallbackFunction invoked on each inspect event.--

Returns: () => void -- an unsubscribe function.

typescript
import { inspect } from '@stateloom/devtools';

const unsub = inspect((event) => {
  console.log(`[${event.storeName}] ${event.type}`, event.state);
});

unsub();

Key behaviors:

  • Callbacks are invoked synchronously in registration order
  • The registry is global (module-level singleton) -- all stores using devtools() emit to the same callbacks
  • Inspector callback errors are caught and swallowed to prevent blocking stores or other inspectors
  • The returned unsubscribe function is idempotent

See also: devtools()


clearInspectors(): void

Clear all registered inspectors. Intended for testing only.


InspectEventType

Event type constants emitted by the inspect system.

typescript
const InspectEventType = {
  SET: 'set',
  INIT: 'init',
  DESTROY: 'destroy',
} as const;

InspectEvent (interface)

Payload emitted to inspect callbacks.

typescript
interface InspectEvent {
  readonly type: InspectEventTypeValue; // 'set' | 'init' | 'destroy'
  readonly storeName: string;
  readonly action?: string; // only for 'set' events
  readonly state: unknown;
  readonly prevState?: unknown; // only for 'set' events
  readonly timestamp: number;
}

DevtoolsOptions (interface)

typescript
interface DevtoolsOptions {
  readonly name?: string;
  readonly enabled?: boolean;
  readonly actionName?: (partial: Record<string, unknown>) => string;
}

LoggerOptions (interface)

typescript
interface LoggerOptions {
  readonly name?: string;
  readonly collapsed?: boolean;
  readonly diff?: boolean;
  readonly enabled?: boolean;
  readonly logger?: LoggerInterface;
  readonly actionName?: (partial: Record<string, unknown>) => string;
}

LoggerInterface (interface)

typescript
interface LoggerInterface {
  log(...args: unknown[]): void;
  group(...args: unknown[]): void;
  groupCollapsed(...args: unknown[]): void;
  groupEnd(): void;
}

Patterns

Development-Only Middleware Stack

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

const isDev = import.meta.env.DEV;

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

Custom Analytics Inspector

typescript
import { inspect, InspectEventType } from '@stateloom/devtools';

inspect((event) => {
  if (event.type === InspectEventType.SET) {
    analytics.track('state_change', {
      store: event.storeName,
      action: event.action,
      timestamp: event.timestamp,
    });
  }
});

Testing with Mock Logger

typescript
import { describe, it, expect, vi } from 'vitest';
import { createStore } from '@stateloom/store';
import { logger } from '@stateloom/devtools';

it('logs state transitions', () => {
  const mockLogger = {
    log: vi.fn(),
    group: vi.fn(),
    groupCollapsed: vi.fn(),
    groupEnd: vi.fn(),
  };

  const store = createStore(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    { middleware: [logger({ logger: mockLogger })] },
  );

  store.getState().inc();
  expect(mockLogger.groupCollapsed).toHaveBeenCalled();
  expect(mockLogger.log).toHaveBeenCalledWith('next state', {
    count: 1,
    inc: expect.any(Function),
  });
});

How It Works

Middleware Pipeline Integration

Both devtools and logger implement the Middleware<T> interface from @stateloom/store. They intercept onSet to capture state before and after the update:

Action Name Inference

By default, action names are derived from the keys in the partial state update:

  • Single key: "set:count"
  • Multiple keys: "set:count,name" (keys are sorted for stability)
  • Empty partial: "setState"

Time-Travel Protocol

When the DevTools Extension dispatches a time-travel message (e.g., JUMP_TO_STATE), the middleware parses the serialized state, sets isTimeTraveling = true, applies the state, then resets the flag. This prevents the state change from being re-sent to the extension.

Inspect System

The inspect function maintains a global Set<InspectCallback>. The devtools middleware calls emitInspectEvent() on init, onSet, and onDestroy, which iterates the set and invokes each callback. Errors in callbacks are caught to prevent cascading failures.

TypeScript

typescript
import { devtools, logger, inspect } from '@stateloom/devtools';
import type { DevtoolsMiddleware, LoggerMiddleware, InspectEvent } from '@stateloom/devtools';
import { expectTypeOf } from 'vitest';

// devtools returns DevtoolsMiddleware<T>
interface State {
  count: number;
}
const dt = devtools<State>({ name: 'Test' });
expectTypeOf(dt).toEqualTypeOf<DevtoolsMiddleware<State>>();

// logger returns LoggerMiddleware<T>
const lg = logger<State>();
expectTypeOf(lg).toEqualTypeOf<LoggerMiddleware<State>>();

// inspect callback receives InspectEvent
inspect((event) => {
  expectTypeOf(event).toEqualTypeOf<InspectEvent>();
});

When to Use

ScenarioWhich API
Development debugging with DevTools Extensiondevtools() middleware
Quick console debugginglogger() middleware
Custom analytics / monitoringinspect() API
Production buildsSet enabled: false on both middlewares
Testing middleware behaviorlogger({ logger: mockLogger })

The devtools package is a development-time dependency for most applications. Use enabled: import.meta.env.DEV to ensure zero overhead in production. For state persistence, use @stateloom/persist instead.