Docs/Developer toolsv1.0.0-rc.1

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 ActionLogEntry capturing 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
  • PerformanceMetrics are accumulated per action type, tracking min/max/average/total dispatch times; each action type retains at most 100 duration samples
  • InspectorRegistry is a static global map of StateInspector instances by store name — useful in multi-store applications without coupling stores together
  • ConsoleInspectorLogger is a ready-made subscriber that formats entries to console.group / console.groupEnd for 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

  • StateInspector works in Node.js and browsers without modification — it has no dependency on window or 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 maxHistorySize is 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
  • InspectorRegistry uses static class members (not a singleton instance); all calls go through InspectorRegistry.register(...), not InspectorRegistry.getInstance().register(...)

Pitfalls

  • Keeping maxHistorySize high with large state trees consumes significant memory — each entry stores two full cloned state snapshots
  • getActionsByType performs a linear scan of the history array; for frequent queries against large histories, build an index from a subscribe listener instead
  • importHistory validates only the version field (must equal 1); importing a snapshot from a different schema version silently replaces history with the raw parsed data
  • ConsoleInspectorLogger calls console.group which is not available in all Node.js environments; in non-browser runtimes use a plain subscribe listener with console.log instead
  • The inspector is enabled by default; calling setEnabled(false) stops recording but does not clear existing history — call clearHistory() explicitly if you also want to reset the log
From the canonical UDIOT public documentation.Try it in the playground ↗