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 beforeconnector.connect()is called - It intercepts the store's
dispatchmethod by replacing it in place, so every action automatically flows throughconnector.send()andinspector.logAction()without changes to the store's own code - Time-travel (JUMP_TO_STATE / JUMP_TO_ACTION) is handled by directly setting
store._stateand callingstore.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()returnsfalseimmediately and no overhead is incurred ConsoleInspectorLoggeris optionally created to mirror action logs to the browser console in colouredconsole.groupblocks
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:
- Creates a
DevToolsConnector(stored asstore.__devtools__) - Creates a
StateInspector(stored asstore.__inspector__) - If
autoConnect: true(default), callsconnector.connect(storeName, store.getState()) - If
consoleLogger: true, creates aConsoleInspectorLoggerattached to the inspector - Wraps
store.dispatchto callconnector.send()andinspector.logAction()after each action - 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(fromreflect-metadata) to store options on the class. Importreflect-metadataat 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.getMetadatacontinue to work when combined with@EnableDevTools store.notifyListenersmust 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 manuallystore.initialStatemust be defined (notundefined) for theRESETDevTools action to work correctlystore._stateis 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 callgetDevToolsConnector(store)synchronously afternew MyStore(), it may returnundefinedbecause setup has not run yet. Access the connector after at least one microtask tick autoConnect: falseprevents the initialconnector.connect()call — you must callgetDevToolsConnector(store)?.connect(name, store.getState())manually when ready- The
consoleLogger: trueoption creates aConsoleInspectorLoggerunconditionally, even when the Redux DevTools Extension is not installed. The logger writes toconsole.groupwhich is not suppressed in production — combine withisProductionEnvironment()guard if you enable it in shared code - Calling
connectDevTools(store, options)on a store that is already decorated with@EnableDevToolscreates a second connector and a second inspector — the store ends up with both attached, leading to duplicate log entries. UsedisconnectDevTools(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 ↗