StateInspector
Overview
StateInspector is an in-process debugging utility that records a chronological history of dispatched actions along with before/after state snapshots and per-action timing data. Unlike the Redux DevTools connector (which requires a browser extension), StateInspector works in any environment — browser, Node.js, or test runners — making it the preferred tool for automated testing and CI pipelines. Source: src/devtools/inspector.ts.
Key Concepts
- Each action dispatch produces an
ActionLogEntrycapturing the previous state, next state, action payload, wall-clock timestamp, and optional execution duration - History is capped at a configurable
maxHistorySize(default 50); the oldest entry is evicted when the cap is hit to prevent unbounded memory growth PerformanceMetricsare accumulated per action type, tracking min/max/average/total dispatch times; each action type retains at most 100 duration samplesInspectorRegistryis a static global map ofStateInspectorinstances by store name — useful in multi-store applications without coupling stores togetherConsoleInspectorLoggeris a ready-made subscriber that formats entries toconsole.group/console.groupEndfor readable browser console output
API Reference
InspectorConfig
interface InspectorConfig {
enabled?: boolean; // Activate the inspector (default: true)
maxHistorySize?: number; // Maximum ActionLogEntry records kept (default: 50)
includeTimestamp?: boolean; // Attach timestamp to each entry (default: true)
trackPerformance?: boolean; // Accumulate PerformanceMetrics per action type (default: true)
}
ActionLogEntry
interface ActionLogEntry {
id: number; // Auto-incrementing entry ID (starts at 1)
action: any; // The dispatched action object
timestamp: number; // Unix ms when the action was dispatched
prevState: any; // Deep clone of state before dispatch
nextState: any; // Deep clone of state after dispatch
duration?: number; // Time (ms) taken to process the action (optional)
error?: Error; // Populated if the dispatch threw
}
Both prevState and nextState are deep-cloned at capture time via JSON.parse(JSON.stringify(...)). Non-serialisable values are replaced with { __error: 'State not serializable' }.
PerformanceMetrics
interface PerformanceMetrics {
actionType: string;
count: number;
totalDuration: number;
avgDuration: number;
maxDuration: number;
minDuration: number;
}
Metrics are sorted by totalDuration descending when returned by getPerformanceMetrics().
StateInspector
class StateInspector {
constructor(config?: InspectorConfig)
// Record an action dispatch with before/after states
logAction(action: any, prevState: any, nextState: any, duration?: number, error?: Error): void
// Retrieve the full action history (oldest first)
getHistory(): ActionLogEntry[]
// Retrieve a single entry by numeric ID
getAction(id: number): ActionLogEntry | undefined
// Retrieve all entries where action.type matches
getActionsByType(type: string): ActionLogEntry[]
// Clear all recorded history and accumulated performance data
clearHistory(): void
// Compute a structural diff between two entries by ID
getStateDiff(fromId: number, toId: number): any
// Retrieve per-action-type performance metrics (sorted by totalDuration desc)
getPerformanceMetrics(): PerformanceMetrics[]
// Retrieve entries whose duration exceeds thresholdMs (default: 16)
getSlowActions(thresholdMs?: number): ActionLogEntry[]
// Export full history and performance data as a JSON string
exportHistory(): string
// Import a previously exported JSON string; returns false on failure
importHistory(json: string): boolean
// Subscribe to new entries as they arrive; returns unsubscribe function
subscribe(listener: (entry: ActionLogEntry) => void): () => void
// Enable or disable the inspector without clearing history
setEnabled(enabled: boolean): void
// Returns true when the inspector is active
isEnabled(): boolean
// Summary statistics for the current session
getStatistics(): {
totalActions: number
actionTypes: number
timeRange: { start: number; end: number } | null
errorCount: number
}
}
createInspector
function createInspector(config?: InspectorConfig): StateInspector
InspectorRegistry
Static (not singleton-instance) registry — all methods are static.
class InspectorRegistry {
static register(storeName: string, inspector: StateInspector): void
static get(storeName: string): StateInspector | undefined
static unregister(storeName: string): void
static getAll(): Map<string, StateInspector>
static clear(): void
static getCombinedStats(): Record<string, ReturnType<StateInspector['getStatistics']>>
}
ConsoleInspectorLogger
class ConsoleInspectorLogger {
constructor(inspector: StateInspector)
// Subscribes automatically on construction; no separate attach() call needed
}
The logger writes coloured console.group blocks showing action type, timestamp, prevState, action payload, nextState, and duration.
Basic Usage
Attaching an inspector to a store
import { StateInspector } from '@tagna/udiot';
class CounterStore {
private _state = { count: 0 };
private inspector = new StateInspector({ maxHistorySize: 100 });
dispatch(action: { type: string }): void {
const prevState = { ...this._state };
const start = performance.now();
try {
this.reduce(action);
this.inspector.logAction(action, prevState, this._state, performance.now() - start);
} catch (error) {
this.inspector.logAction(action, prevState, this._state, performance.now() - start, error as Error);
throw error;
}
}
private reduce(action: { type: string }): void {
if (action.type === 'INCREMENT') this._state = { count: this._state.count + 1 };
if (action.type === 'DECREMENT') this._state = { count: this._state.count - 1 };
}
getInspector(): StateInspector { return this.inspector; }
getState() { return this._state; }
}
const store = new CounterStore();
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'DECREMENT' });
const history = store.getInspector().getHistory();
console.log(`Recorded ${history.length} actions`);
// Recorded 3 actions
Enabling the console logger
import { StateInspector, ConsoleInspectorLogger } from '@tagna/udiot';
const inspector = new StateInspector();
// Logger subscribes immediately in the constructor
new ConsoleInspectorLogger(inspector);
Common Patterns
Filtering history by action type
const increments = inspector.getActionsByType('INCREMENT');
console.log(`INCREMENT called ${increments.length} times`);
const errored = inspector.getHistory().filter(e => e.error != null);
console.log(`${errored.length} dispatches threw errors`);
Finding slow actions
// Actions that took more than 50 ms
const slowActions = inspector.getSlowActions(50);
slowActions.forEach(e => {
console.warn(`Slow action: ${e.action.type} took ${e.duration?.toFixed(1)}ms`);
});
Comparing state between two actions
// What changed between action 3 and action 7?
const diff = inspector.getStateDiff(3, 7);
console.log(diff);
// { count: { type: 'changed', oldValue: 2, newValue: 6 } }
Subscribing to live action feeds
const unsubscribe = inspector.subscribe((entry) => {
if (entry.duration && entry.duration > 50) {
console.warn(`Slow action: ${entry.action.type} took ${entry.duration.toFixed(1)}ms`);
}
});
// Clean up
unsubscribe();
Exporting and importing history
// Capture a session
const snapshot = inspector.exportHistory();
// { "version": 1, "timestamp": ..., "history": [...], "performance": [...] }
// Restore in a fresh inspector
const freshInspector = new StateInspector();
const ok = freshInspector.importHistory(snapshot);
console.log(ok ? 'Restored' : 'Import failed');
Multi-store inspection with InspectorRegistry
import { InspectorRegistry, StateInspector } from '@tagna/udiot';
const counterInspector = new StateInspector({ maxHistorySize: 200 });
const cartInspector = new StateInspector({ trackPerformance: true });
InspectorRegistry.register('counter', counterInspector);
InspectorRegistry.register('cart', cartInspector);
// Combined stats across all registered stores
const stats = InspectorRegistry.getCombinedStats();
// Teardown in tests
afterEach(() => InspectorRegistry.clear());
Reviewing performance metrics
const metrics = inspector.getPerformanceMetrics(); // sorted by totalDuration desc
for (const m of metrics) {
console.log(
`${m.actionType}: avg=${m.avgDuration.toFixed(2)}ms ` +
`min=${m.minDuration.toFixed(2)}ms max=${m.maxDuration.toFixed(2)}ms ` +
`count=${m.count}`
);
}
Integration Notes
StateInspectorworks in Node.js and browsers without modification — it has no dependency onwindowor any browser API- State deep-cloning uses
JSON.parse(JSON.stringify(...)). Non-serialisable values (functions,undefined, circular references) are replaced with{ __error: 'State not serializable' }so the inspector never throws during logging - History entries are evicted oldest-first once
maxHistorySizeis reached — the array always reflects the most recent N actions - Performance data per action type retains at most 100 duration samples; the oldest sample is evicted (
shift) when this limit is exceeded InspectorRegistryuses static class members (not a singleton instance); all calls go throughInspectorRegistry.register(...), notInspectorRegistry.getInstance().register(...)
Pitfalls
- Keeping
maxHistorySizehigh with large state trees consumes significant memory — each entry stores two full cloned state snapshots getActionsByTypeperforms a linear scan of the history array; for frequent queries against large histories, build an index from asubscribelistener insteadimportHistoryvalidates only theversionfield (must equal1); importing a snapshot from a different schema version silently replaces history with the raw parsed dataConsoleInspectorLoggercallsconsole.groupwhich is not available in all Node.js environments; in non-browser runtimes use a plainsubscribelistener withconsole.loginstead- The inspector is enabled by default; calling
setEnabled(false)stops recording but does not clear existing history — callclearHistory()explicitly if you also want to reset the log