Docs/State & reactivityv1.0.0-rc.1

Store — API Reference

@tagna/udiot v1.0.0-rc.1


Abstract Class: Store<TState>

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

abstract class Store<TState = any> {
  protected abstract reduce(state: TState, action: Action): TState;
  // ...
}

Store must be subclassed. The subclass provides an initial state (passed to the super() constructor) and implements the abstract reduce() method.


Constructor

constructor(initialState: TState, options?: StoreOptions)

StoreOptions

interface StoreOptions {
  maxHistory?: number;      // Maximum number of state snapshots to keep. Default: 50.
  enableDevTools?: boolean; // Connect to Redux DevTools on construction. Default: false.
  aiOptimizations?: boolean; // Enable AI analysis on construction. Default: false.
}

The initial state is deep-frozen immediately. An @@INIT snapshot is recorded in the history buffer.

interface CounterState {
  count: number;
}

class CounterStore extends Store<CounterState> {
  constructor() {
    super({ count: 0 }, { maxHistory: 100 });
  }

  protected reduce(state: CounterState, action: Action): CounterState {
    switch (action.type) {
      case 'INCREMENT': return { count: state.count + 1 };
      case 'DECREMENT': return { count: state.count - 1 };
      case 'RESET':     return { count: 0 };
      default:          return state;
    }
  }
}

State Access

getState()

getState(): TState

Returns the current immutable state snapshot. The returned object is deep-frozen; any attempt to mutate it will throw in strict mode.

const state = store.getState();
console.log(state.count); // 0

Dispatch

dispatch(action)

async dispatch(action: Action): Promise<void>

Triggers a state update. The execution order on every dispatch:

  1. Create a StateContext wrapping the current store and action.
  2. Run the middleware pipeline (may transform action, or consume it by not calling next()).
  3. At the end of the pipeline, call reduce(currentState, finalAction) to compute the next state.
  4. If the state reference changed, deep-freeze the new state.
  5. Push a StateSnapshot to the history buffer.
  6. Notify all subscribers, including the observable from asObservable() (once per change).
  7. Middleware code after await next() runs and sees the new state.

dispatch is async because the middleware pipeline is async. Awaiting it guarantees all middleware and listener calls have completed. It rejects when a middleware or the reducer throws, and when something other than an action object reaches the reducer (dispatch functions through thunkMiddleware()).

replaceState(state, type?, options?)

replaceState(state: TState, type?: string, options?: { recordHistory?: boolean }): void

Replaces the whole state without running the reducer, freezes it and notifies listeners with an action of type (default '@@REPLACE'). Meant for tooling: persistence, DevTools time travel and imports. Pass recordHistory: false to leave the history untouched.

await store.dispatch({ type: 'INCREMENT' });
await store.dispatch({ type: 'RESET', payload: null });

Action Interface

interface Action {
  type: string;    // Required. Identifies the action.
  payload?: any;   // Optional data for the reducer.
  meta?: any;      // Optional metadata (timestamps, request IDs, etc.)
  error?: boolean; // True when this action carries an error payload.
}

Subscription

subscribe(listener)

subscribe(listener: StateListener<TState>): Unsubscribe

Registers a listener that is called after every successful dispatch that changes state. Returns an Unsubscribe function.

type StateListener<TState> = (
  state: TState,
  prevState: TState,
  action: Action
) => void;

type Unsubscribe = () => void;
const stop = store.subscribe((state, prev, action) => {
  console.log(`[${action.type}] count: ${prev.count} → ${state.count}`);
});

// Remove listener when done
stop();

Time-Travel

undo()

undo(): void

Reverts to the previous snapshot in history. No-op if already at the oldest snapshot. Notifies subscribers with action type '@@UNDO'.

redo()

redo(): void

Re-applies the next snapshot in history. No-op if already at the most recent snapshot. Notifies subscribers with '@@REDO'.

jumpToSnapshot(index)

jumpToSnapshot(index: number): void

Jumps directly to the snapshot at position index in the history array. Out-of-range indices are silently ignored. Notifies subscribers with '@@JUMP'.

getHistory()

getHistory(): StateSnapshot<TState>[]

Returns a copy of all recorded snapshots. The array length is at most maxHistory.

StateSnapshot<TState>

interface StateSnapshot<TState> {
  state: TState;    // Deep clone of state at this point
  timestamp: number; // Unix milliseconds
  action: string;   // The action type that produced this snapshot
}

getHistoryIndex()

getHistoryIndex(): number

Returns the index of the currently active snapshot within the history buffer.

canUndo() / canRedo()

canUndo(): boolean
canRedo(): boolean

Convenience checks for time-travel controls.

clearHistory()

clearHistory(): void

Discards all history and records a fresh @@CLEAR_HISTORY snapshot of the current state.


Middleware

use(middleware)

use(middleware: Middleware | PipelineMiddleware<StateContext<TState>>): this

Adds middleware to the dispatch pipeline. Supports both the new pipeline format and the legacy two-argument format for backward compatibility. Returns this for chaining.

New pipeline format (recommended):

store.use({
  name: 'logger',
  handler: async (ctx, next) => {
    console.log('before:', ctx.action.type);
    await next();
    console.log('after:', ctx.action.type);
  }
});

Legacy format:

store.use((store, action) => {
  console.log('action:', action.type);
  return action;
});

Observable Integration

asObservable()

asObservable(): Observable<TState>

Returns the store's state as a BehaviorSubject-backed Observable<TState>. New subscribers receive the current state immediately. Only one BehaviorSubject is created per store instance.

select(selector)

select<K>(selector: (state: TState) => K): Observable<K>

Returns an Observable that emits the projected value every time the state changes. Uses .map() on the underlying BehaviorSubject.

const count$ = store.select(s => s.count);
count$.subscribe(n => console.log('count:', n));

selectMemoized(selector)

selectMemoized<K>(selector: (state: TState) => K): Observable<K>

Like select() but wraps the selector with createSelector() memoization before mapping. Use this for computationally expensive derived values.


Persistence

enablePersistence(options)

enablePersistence(options: PersistenceOptions<TState>): void

Wires a StatePersister to the store. State is loaded asynchronously on call and saved (debounced) on every subsequent state change. See persistence.md for PersistenceOptions.


Other Methods

getListenerCount()

getListenerCount(): number

Returns the number of active subscribers.

getPipeline()

getPipeline(): MiddlewarePipeline<StateContext<TState>>

Returns the internal middleware pipeline for inspection or testing.


createStore() Function

For simple use cases that do not need a subclass:

function createStore<TState>(
  initialState: TState,
  reducer: (state: TState, action: Action) => TState,
  options?: StoreOptions
): Store<TState>
const store = createStore(
  { count: 0 },
  (state, action) => {
    switch (action.type) {
      case 'INC': return { count: state.count + 1 };
      default:    return state;
    }
  }
);

Complete Example: CounterStore

import { Store, type StoreAction as Action } from '@tagna/udiot';
import { loggerMiddleware } from '@tagna/udiot';

interface CounterState {
  count: number;
  step: number;
}

class CounterStore extends Store<CounterState> {
  constructor() {
    super({ count: 0, step: 1 });

    // Add logger middleware
    this.use(loggerMiddleware({ timestamp: true }));
  }

  protected reduce(state: CounterState, action: Action): CounterState {
    switch (action.type) {
      case 'INCREMENT':
        return { ...state, count: state.count + state.step };
      case 'DECREMENT':
        return { ...state, count: state.count - state.step };
      case 'SET_STEP':
        return { ...state, step: action.payload as number };
      case 'RESET':
        return { count: 0, step: 1 };
      default:
        return state;
    }
  }
}

// Usage
const store = new CounterStore();

const unsubscribe = store.subscribe((state, prev, action) => {
  console.log(`[${action.type}] ${prev.count} → ${state.count}`);
});

await store.dispatch({ type: 'INCREMENT' });        // 0 → 1
await store.dispatch({ type: 'SET_STEP', payload: 5 });
await store.dispatch({ type: 'INCREMENT' });        // 1 → 6
await store.dispatch({ type: 'DECREMENT' });        // 6 → 1

store.undo();  // back to 6
store.redo();  // forward to 1

console.log(store.getHistory().length); // 5 snapshots (@@INIT + 4 dispatches)

unsubscribe();

Pitfalls

Dispatching inside a subscriber (infinite loop)

A subscriber that calls dispatch() will trigger all subscribers again, which may call dispatch() again. Guard against this with a condition check:

store.subscribe((state, prev, action) => {
  if (action.type === 'SET_STEP') return; // avoid recursion
  if (state.count > 100) {
    store.dispatch({ type: 'SET_STEP', payload: 10 });
  }
});

Not unsubscribing (memory leak)

subscribe() adds a function to an internal Set. If the subscribing object is long-lived but the store is not, the subscriber keeps the object alive. Always call the returned unsubscribe() function when the subscriber's lifecycle ends.

class MyComponent {
  private unsubscribe!: () => void;

  onMount(): void {
    this.unsubscribe = store.subscribe(state => this.render(state));
  }

  onUnmount(): void {
    this.unsubscribe();
  }
}
From the canonical UDIOT public documentation.Try it in the playground ↗