Docs/State & reactivityv1.0.0-rc.1

State Middleware — Reference

@tagna/udiot v1.0.0-rc.1


What Is State Middleware?

State middleware intercepts every call to store.dispatch(). Each middleware sits in a sequential pipeline. A middleware can:

  • Inspect or log the action and state before or after the next step runs.
  • Transform the action (replace ctx.action).
  • Prevent the action from reaching the reducer by not calling next().
  • Dispatch additional actions.
  • Perform async work between the action arriving and the reducer running.

The reducer runs at the end of the pipeline, inside the last next(), so code after await next() sees the new state. store.dispatch() rejects when a middleware or the reducer throws, and when something other than an action object (such as a function, without thunk middleware) reaches the reducer. An action of type BATCH_ACTION ('@@BATCH') whose payload is an array of actions is applied in one step; listeners and history see a single change.


The new pipeline format is an object with a handler async function:

interface Middleware<TContext> {
  name: string;
  handler: (context: TContext, next: () => Promise<void>) => Promise<void>;
  metadata?: Record<string, any>;
}

For state middleware the context is StateContext<TState>:

interface StateContext<TState> {
  store: Store<TState>;
  action: Action;          // Replace this to transform the action
  metadata: Map<string, any>;
}
store.use({
  name: 'myLogger',
  handler: async (ctx, next) => {
    console.log('>> dispatching', ctx.action.type, ctx.store.getState());
    await next(); // later middleware, then the reducer
    console.log('<< done', ctx.store.getState()); // the new state
  }
});

Middleware runs in priority order (lower first, default 0), then in the order it was added.


Legacy Middleware (Backward Compatible)

The two-argument function form still works and is automatically adapted to the new pipeline:

type Middleware = (store: Store, action: Action) => Action | void;

Return the (potentially modified) action to let it continue. Returning nothing (or null) consumes the action: later middleware and the reducer do not run. Every function passed to store.use() is treated as this legacy form.

store.use((store, action) => {
  console.log('action:', action.type);
  return action; // must return the action to continue
});

thunkMiddleware()

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

function thunkMiddleware<TState = any>(): Middleware

Enables dispatching functions (thunks) in place of plain action objects. When the "action" is a function it is called with the store; it does not reach the reducer. await store.dispatch(thunk) waits for the thunk. If the thunk throws, the error is logged and an @@THUNK_ERROR action carrying it is dispatched; the dispatch still resolves.

store.use(thunkMiddleware());

// Now you can dispatch a function
await store.dispatch(async (s) => {
  const data = await fetch('/api/users').then(r => r.json());
  await s.dispatch({ type: 'SET_USERS', payload: data });
});

createThunk(fn)

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

function createThunk<TState, TArgs extends any[], TResult>(
  fn: (store: Store<TState>, ...args: TArgs) => TResult | Promise<TResult>
): (...args: TArgs) => ThunkAction<TState, TResult>

Factory for a named, reusable thunk creator. The returned function accepts the action arguments and returns a ThunkAction (a function) ready to be dispatched.

const loadUser = createThunk(
  async (store: Store<AppState>, userId: string) => {
    const user = await userApi.getById(userId);
    store.dispatch({ type: 'SET_USER', payload: user });
  }
);

// Dispatch it like a normal action
store.dispatch(loadUser('user-42'));

createAsyncThunk(typePrefix, payloadCreator)

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

function createAsyncThunk<TState, TArgs extends any[], TResult>(
  typePrefix: string,
  payloadCreator: (store: Store<TState>, ...args: TArgs) => Promise<TResult>
): (...args: TArgs) => ThunkAction<TState, Promise<TResult>>

Automatically dispatches three lifecycle actions:

Action type When
${typePrefix}/pending Immediately when the thunk starts
${typePrefix}/fulfilled When payloadCreator resolves
${typePrefix}/rejected When payloadCreator throws

The thunk then rethrows the payload creator's error; thunkMiddleware() does not report it again as @@THUNK_ERROR. A reducer that throws on the fulfilled action fails the thunk without a rejected action.

const fetchUsers = createAsyncThunk(
  'users/fetch',
  async (store: Store<AppState>) => {
    const response = await fetch('/api/users');
    if (!response.ok) throw new Error('Network error');
    return response.json() as Promise<User[]>;
  }
);

Handle the lifecycle actions in the reducer:

protected reduce(state: AppState, action: Action): AppState {
  switch (action.type) {
    case 'users/fetch/pending':
      return { ...state, loading: true, error: null };
    case 'users/fetch/fulfilled':
      return { ...state, loading: false, users: action.payload as User[] };
    case 'users/fetch/rejected':
      return { ...state, loading: false, error: (action.payload as Error).message };
    default:
      return state;
  }
}

loggerMiddleware(options?)

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

function loggerMiddleware(options?: LoggerOptions): Middleware<StateContext>

A pipeline middleware that logs each action with the state before it and, once the reducer has run, the state after it.

LoggerOptions

interface LoggerOptions {
  timestamp?: boolean;              // Print time. Default: true
  duration?: boolean;               // Print elapsed ms. Default: true
  diff?: boolean;                   // Print changed top-level keys. Default: false
  predicate?: (store, action) => boolean;  // Filter which actions to log
  actionTransformer?: (action) => any;     // Transform action before logging
  stateTransformer?: (state) => any;       // Transform state before logging
}
store.use(loggerMiddleware({
  timestamp: true,
  diff: true,
  predicate: (_, action) => !action.type.startsWith('@@')
}));

performanceLogger(options?)

Times each action, including the middleware after it and the reducer, and warns about actions slower than slowThresholdMs (default 16).

const perf = performanceLogger({ slowThresholdMs: 8 });
store.use(perf);
// later
perf.getStats(); // { [type]: { count, avgMs, maxMs } }
perf.reset();

StateMiddlewareAdapter

When you need to use old-style middleware with the new pipeline (or vice versa), use the adapter functions exported from @tagna/udiot.

adaptLegacyStateMiddleware(legacyMiddleware)

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

function adaptLegacyStateMiddleware<TState>(
  legacyMiddleware: (store: Store<TState>, action: Action) => Action | void
): Middleware<StateContext<TState>>

Wraps a two-argument legacy middleware in the new pipeline object format.

const myLegacyLogger = (store: Store, action: Action) => {
  console.log('legacy log:', action.type);
  return action;
};

const adapted = adaptLegacyStateMiddleware(myLegacyLogger);
store.use(adapted); // works with new pipeline

adaptPipelineToLegacy(pipelineMiddleware)

function adaptPipelineToLegacy<TState>(
  middleware: Middleware<StateContext<TState>>
): (store: Store<TState>, action: Action) => Action

The reverse adapter. Legacy middleware is synchronous, so only what the handler does before its first await is visible: an action it assigns to ctx.action by then is returned, and next resolves immediately.

legacyAdapters

A convenience namespace of pre-built legacy adapter wrappers:

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

store.use(legacyAdapters.thunk());   // Redux-style thunks: (dispatch, getState)
store.use(legacyAdapters.logger({ diff: true }));  // same as loggerMiddleware()
store.use(legacyAdapters.promise()); // FOO_PENDING, then FOO_FULFILLED or FOO_REJECTED
store.use(legacyAdapters.debounce({ SEARCH: 300 })); // only the last SEARCH in 300 ms

promise() and debounce() consume the original action; only the actions they dispatch reach the reducer.


Example: Async Data Fetch with createAsyncThunk

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

interface User { id: string; name: string; email: string; }

interface UsersState {
  users: User[];
  loading: boolean;
  error: string | null;
}

// 1. Define the async thunk
const fetchUsers = createAsyncThunk<UsersState, [], User[]>(
  'users/fetch',
  async () => {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
);

// 2. Create the store
class UsersStore extends Store<UsersState> {
  constructor() {
    super({ users: [], loading: false, error: null });
    this.use(thunkMiddleware());
  }

  protected reduce(state: UsersState, action: Action): UsersState {
    switch (action.type) {
      case 'users/fetch/pending':
        return { ...state, loading: true, error: null };
      case 'users/fetch/fulfilled':
        return { ...state, loading: false, users: action.payload as User[] };
      case 'users/fetch/rejected':
        return { ...state, loading: false, error: (action.payload as Error).message };
      default:
        return state;
    }
  }
}

// 3. Use it
const store = new UsersStore();

store.subscribe((state, _, action) => {
  if (action.type.startsWith('users/fetch')) {
    console.log(action.type, '→ loading:', state.loading, 'users:', state.users.length);
  }
});

await store.dispatch(fetchUsers()); // dispatches pending → fulfilled (or rejected)
From the canonical UDIOT public documentation.Try it in the playground ↗