@stateloom/devtools
Redux DevTools Extension bridge, console logger, and custom inspector API for StateLoom stores.
Install
pnpm add @stateloom/devtoolsnpm install @stateloom/devtoolsyarn add @stateloom/devtoolsSize: ~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
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.
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:
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:
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:
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:
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:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | DevtoolsOptions | Configuration options. | undefined |
options.name | string | Display name in the DevTools panel. | "StateLoom Store" |
options.enabled | boolean | Whether the middleware is active. | true |
options.actionName | (partial: Record<string, unknown>) => string | Custom action name derivation. | Auto-inferred |
Returns: DevtoolsMiddleware<T> -- a middleware instance compatible with createStore.
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
isTimeTravelingguard prevents re-sending state changes to the extension - Emits inspect events for
init,set, anddestroylifecycle hooks - Set
enabled: falseto disable with zero overhead
logger<T>(options?: LoggerOptions): LoggerMiddleware<T>
Create a console logging middleware.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | LoggerOptions | Configuration options. | undefined |
options.name | string | Display name in console output. | "StateLoom" |
options.collapsed | boolean | Use collapsed console groups. | true |
options.diff | boolean | Show shallow diff of changed keys. | false |
options.enabled | boolean | Whether the logger is active. | true |
options.logger | LoggerInterface | Custom console-like logger. | console |
options.actionName | (partial: Record<string, unknown>) => string | Custom action name derivation. | Auto-inferred |
Returns: LoggerMiddleware<T> -- a middleware instance compatible with createStore.
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.groupCollapsedby default for clean output - Logs timestamps in
HH:MM:SS.mmmformat - The
diffoption shows a shallow diff of changed keys (top-level only) - When
enabled: false, becomes a pass-through that callsnext(partial)with zero overhead - The
loggeroption accepts any object withlog,group,groupCollapsed, andgroupEnd-- useful for testing
See also: devtools()
inspect(callback: InspectCallback): () => void
Register a global inspector callback for custom devtools.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
callback | InspectCallback | Function invoked on each inspect event. | -- |
Returns: () => void -- an unsubscribe function.
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.
const InspectEventType = {
SET: 'set',
INIT: 'init',
DESTROY: 'destroy',
} as const;InspectEvent (interface)
Payload emitted to inspect callbacks.
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)
interface DevtoolsOptions {
readonly name?: string;
readonly enabled?: boolean;
readonly actionName?: (partial: Record<string, unknown>) => string;
}LoggerOptions (interface)
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)
interface LoggerInterface {
log(...args: unknown[]): void;
group(...args: unknown[]): void;
groupCollapsed(...args: unknown[]): void;
groupEnd(): void;
}Patterns
Development-Only Middleware Stack
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
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
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
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
| Scenario | Which API |
|---|---|
| Development debugging with DevTools Extension | devtools() middleware |
| Quick console debugging | logger() middleware |
| Custom analytics / monitoring | inspect() API |
| Production builds | Set enabled: false on both middlewares |
| Testing middleware behavior | logger({ 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.