Docs/Developer toolsv1.0.0-rc.1

DevTools Decorator

Overview

@EnableDevTools is a TypeScript class decorator that automatically wires a store to the Redux DevTools Extension and the in-process StateInspector without any manual connector setup in the constructor. It intercepts dispatch, forwards every action and state snapshot to the extension, and handles time-travel messages (jump, reset, import) from the DevTools panel. The programmatic equivalents connectDevTools(store, options?) and disconnectDevTools(store) provide the same behaviour without the decorator syntax. Source: src/decorators/devtools.ts.

Key Concepts

  • The decorator wraps the class constructor using setTimeout(..., 0) to defer DevTools setup until after construction completes — this ensures the store's initial state is fully initialised before connector.connect() is called
  • It intercepts the store's dispatch method by replacing it in place, so every action automatically flows through connector.send() and inspector.logAction() without changes to the store's own code
  • Time-travel (JUMP_TO_STATE / JUMP_TO_ACTION) is handled by directly setting store._state and calling store.notifyListeners() — this matches UDIOT's internal store convention
  • In production (isProductionEnvironment() returns true), the extension warning is suppressed, but the decorator still defers setup — DevToolsConnector.connect() returns false immediately and no overhead is incurred
  • ConsoleInspectorLogger is optionally created to mirror action logs to the browser console in coloured console.group blocks

API Reference

EnableDevToolsOptions

interface EnableDevToolsOptions {
  devTools?: DevToolsConfig;     // Passed directly to DevToolsConnector (see devtools-connector.md)
  inspector?: InspectorConfig;   // Passed directly to StateInspector (see state-inspector.md)
  consoleLogger?: boolean;       // Create a ConsoleInspectorLogger (default: false)
  autoConnect?: boolean;         // Automatically call connector.connect() (default: true)
}

@EnableDevTools

function EnableDevTools(options?: EnableDevToolsOptions): ClassDecorator

Applying @EnableDevTools to a class does the following after construction:

  1. Creates a DevToolsConnector (stored as store.__devtools__)
  2. Creates a StateInspector (stored as store.__inspector__)
  3. If autoConnect: true (default), calls connector.connect(storeName, store.getState())
  4. If consoleLogger: true, creates a ConsoleInspectorLogger attached to the inspector
  5. Wraps store.dispatch to call connector.send() and inspector.logAction() after each action
  6. Subscribes to DevTools panel messages to handle time-travel, reset, and state import

Attached instance properties

store.__devtools__: DevToolsConnector  // The Redux DevTools connector
store.__inspector__: StateInspector    // The in-process state inspector

Helper functions

// Retrieve the DevToolsConnector attached by @EnableDevTools or connectDevTools
function getDevToolsConnector(store: any): DevToolsConnector | undefined

// Retrieve the StateInspector attached by @EnableDevTools or connectDevTools
function getInspector(store: any): StateInspector | undefined

// Returns true when the store has been connected to DevTools
function hasDevTools(store: any): boolean

// Read the EnableDevToolsOptions stored in class metadata
function getDevToolsMetadata(target: any): DevToolsMetadata | undefined

// Programmatic alternative to the decorator
function connectDevTools(store: any, options?: EnableDevToolsOptions): void

// Disconnect and disable the connector for a store
function disconnectDevTools(store: any): void

DevTools message handling

The decorator subscribes to connector.onMessage() and handles the following DevTools action types:

DevToolsActionType Effect
JUMP_TO_STATE Sets store._state to JSON.parse(message.state) and calls store.notifyListeners()
JUMP_TO_ACTION Same as JUMP_TO_STATE
RESET Sets store._state to store.initialState and calls store.notifyListeners()
IMPORT_STATE Restores the last entry from payload.nextLiftedState.computedStates
COMMIT, ROLLBACK, TOGGLE_ACTION, others No-op (acknowledged, not applied)

Basic Usage

Applying the decorator

import { EnableDevTools } from '@tagna/udiot';

@EnableDevTools({ consoleLogger: true })
class TodoStore {
  private _state = { todos: [] as string[], filter: 'all' };
  readonly initialState = { todos: [], filter: 'all' };

  getState() { return this._state; }

  dispatch(action: { type: string; payload?: any }) {
    switch (action.type) {
      case 'ADD_TODO':
        this._state = {
          ...this._state,
          todos: [...this._state.todos, action.payload],
        };
        break;
      case 'SET_FILTER':
        this._state = { ...this._state, filter: action.payload };
        break;
    }
  }

  notifyListeners(prevState: any, nextState: any, action: any) {
    // UDIOT stores call this internally; the DevTools decorator hooks into it
    // for time-travel state restoration
  }
}

const store = new TodoStore();
store.dispatch({ type: 'ADD_TODO', payload: 'Write documentation' });
// The action and updated state appear in the Redux DevTools panel.

Programmatic alternative

import { connectDevTools, disconnectDevTools } from '@tagna/udiot';

const store = new TodoStore();

connectDevTools(store, {
  devTools:  { maxAge: 100, trace: true },
  inspector: { maxHistorySize: 200 },
  consoleLogger: true,
  autoConnect:   true,
});

// When done (e.g., in test teardown):
disconnectDevTools(store);

Checking whether DevTools are connected

import { hasDevTools, getDevToolsConnector } from '@tagna/udiot';

const store = new TodoStore();
console.log(hasDevTools(store));             // true
console.log(getDevToolsConnector(store)?.isConnected()); // true if extension present

Common Patterns

Inspecting action history after dispatch

import { getInspector } from '@tagna/udiot';

const store = new TodoStore();
store.dispatch({ type: 'ADD_TODO', payload: 'Fix bug' });
store.dispatch({ type: 'ADD_TODO', payload: 'Add tests' });

const inspector = getInspector(store);
if (inspector) {
  const history = inspector.getHistory();
  console.log(`${history.length} actions recorded`);

  const slow = inspector.getSlowActions(5); // > 5 ms
  console.log(`${slow.length} slow actions`);

  const stats = inspector.getStatistics();
  console.log(stats);
}

Disconnecting in test teardown

import { disconnectDevTools } from '@tagna/udiot';

afterEach(() => {
  disconnectDevTools(store);
});

Combining with @PreserveState for full HMR + DevTools support

import { EnableDevTools } from '@tagna/udiot';
import { PreserveState } from '@tagna/udiot/dev';

// Apply @EnableDevTools before @PreserveState so the inner decorator
// runs first (decorators apply bottom-up)
@PreserveState({ exclude: ['__devtools__', '__inspector__'] })
@EnableDevTools({ consoleLogger: false })
class AppStore {
  currentUser: string | null = null;
  theme: 'light' | 'dark' = 'light';

  getState() { return { currentUser: this.currentUser, theme: this.theme }; }

  dispatch(action: { type: string; payload?: any }) {
    if (action.type === 'SET_USER')    this.currentUser = action.payload;
    if (action.type === 'TOGGLE_THEME') this.theme = this.theme === 'light' ? 'dark' : 'light';
  }
}

Configuring DevTools and Inspector separately

import { EnableDevTools } from '@tagna/udiot';

@EnableDevTools({
  devTools: {
    maxAge:       100,
    trace:        true,
    traceLimit:   5,
    features: {
      persist: false,   // Do not persist across page reloads
      test:    false,   // Disable test generation
    },
  },
  inspector: {
    maxHistorySize:  200,
    trackPerformance: true,
  },
  consoleLogger: true,
  autoConnect:   true,
})
class UserStore {
  private _state = { users: [] as any[] };
  readonly initialState = { users: [] };

  getState() { return this._state; }
  dispatch(action: any) { /* ... */ }
}

Integration Notes

  • The decorator uses Reflect.defineMetadata (from reflect-metadata) to store options on the class. Import reflect-metadata at your application entry point: import 'reflect-metadata'
  • Metadata keys are copied from the original class constructor to the wrapped constructor, so other decorators relying on Reflect.getMetadata continue to work when combined with @EnableDevTools
  • store.notifyListeners must be a callable method on the store for time-travel to trigger reactive updates — UDIOT's built-in store base class provides this; custom stores must implement it manually
  • store.initialState must be defined (not undefined) for the RESET DevTools action to work correctly
  • store._state is directly mutated during time-travel — this matches UDIOT's internal convention; stores that use a different internal field name will not respond to time-travel automatically

Pitfalls

  • The decorator defers setup with setTimeout(..., 0) — if you call getDevToolsConnector(store) synchronously after new MyStore(), it may return undefined because setup has not run yet. Access the connector after at least one microtask tick
  • autoConnect: false prevents the initial connector.connect() call — you must call getDevToolsConnector(store)?.connect(name, store.getState()) manually when ready
  • The consoleLogger: true option creates a ConsoleInspectorLogger unconditionally, even when the Redux DevTools Extension is not installed. The logger writes to console.group which is not suppressed in production — combine with isProductionEnvironment() guard if you enable it in shared code
  • Calling connectDevTools(store, options) on a store that is already decorated with @EnableDevTools creates a second connector and a second inspector — the store ends up with both attached, leading to duplicate log entries. Use disconnectDevTools(store) first, or avoid mixing the decorator and the imperative API on the same store instance
From the canonical UDIOT public documentation.Try it in the playground ↗