Skip to content

Concepts

A finite state machine is a model that is always in exactly one state, transitions between states in response to inputs, and can run logic on entry and exit. Everything in machina is an expression of that model.


machina’s methods (handle(), transition(), canHandle(), currentState()) are all synchronous. When you call handle("timeout"), the entire transition sequence (exit → state change → enter → deferred replay) completes before handle() returns.

machina still works in async workflows. A handler starts the async work (a fetch or a timer), and when that work resolves, it feeds a new input back into the FSM. The FSM stays synchronous, and the async world calls back in.

checking: {
  _onEnter({ ctx }) {
    // Async work happens outside the FSM's synchronous boundary
    fetch("/health")
      .then(res => fsm.handle(res.ok ? "passed" : "failed"))
      .catch(() => fsm.handle("failed"));
  },
  passed: "online",
  failed: "offline",
}

The FSM doesn’t await anything. It enters checking, the _onEnter fires the fetch, and handle() returns. Later, when the promise settles, a new handle() call drives the next transition. This keeps state transitions predictable and eliminates an entire class of race conditions.


An FSM is always in exactly one state. States are defined as keys of the states object in your config. TypeScript infers the state names as string literals from those keys, and you don’t declare a separate type.

const light = createFsm({
    id: "traffic-light",
    initialState: "green",
    context: { tickCount: 0 },
    states: {
        green: {/* ... */},
        yellow: {/* ... */},
        red: {/* ... */},
    },
});

"green", "yellow", and "red" are your state names. TypeScript knows them at compile time.


An input is a named signal dispatched to the FSM via handle(inputName, ...args). The FSM looks up the current state’s handler for that input name and runs it.

light.handle("timeout");
light.handle("tick", { source: "timer" });

If the current state has no handler for that input name and no wildcard handler, machina fires a nohandler event and moves on.


A handler is a property on a state object. There are two forms:

Function handler: receives a HandlerArgs object plus any extra args passed to handle(). Return a state name to transition, or return nothing to stay put. Extra args are untyped by default, and an input payload map types them per input.

green: {
  timeout({ ctx }) {
    if (ctx.tickCount >= 3) {
      return "yellow"; // transition
    }
    // return nothing → stay in green
  },
  tick({ ctx }) {
    ctx.tickCount++; // side effect, no transition
  },
}

The HandlerArgs object contains:

Property What it is
ctx The mutable context object (or client, for BehavioralFsm)
inputName The name of the input being handled
defer Function to defer this input for later replay
emit Function to emit a custom event

String shorthand: always transitions to its target state. timeout: "yellow" is exactly equivalent to timeout() { return "yellow"; }.

green: {
  timeout: "yellow",  // unconditional transition to "yellow"
}

Wildcard handler: "*" matches any input that has no handler of its own in that state. Use inputName to identify the input.

green: {
  timeout: "yellow",
  "*"({ inputName }) {
    console.log(`unhandled in green: ${inputName}`);
  },
}

When a handler returns a state name (or a string shorthand resolves), machina runs the transition sequence:

  1. Calls _onExit on the current state (if defined)
  2. Emits a transitioning event: { fromState, toState }
  3. Updates the current state
  4. Calls _onEnter on the new state (if defined)
  5. Emits a transitioned event: { fromState, toState }
  6. Replays any deferred inputs targeting the new state

If _onEnter returns a state name, machina immediately begins another transition, a “bounce” to a third state. This is useful for conditional initialization logic.


Context is the mutable data object available to all handlers as ctx. It’s how handlers share and update state between inputs without reaching for external variables.

For createFsm, you provide context in the config. TypeScript infers the type from the value:

createFsm({
    context: { tickCount: 0, lastEvent: null as string | null },
    // ...
});
// ctx is inferred as { tickCount: number; lastEvent: string | null }

For createBehavioralFsm, there is no separate context object: the client object itself is the context. Each call to handle(client, inputName) passes the client as ctx.

See createFsm and createBehavioralFsm for the full API.


The reserved keys _onEnter and _onExit run automatically during transitions:

_onEnter: called when the FSM enters that state. It receives the same HandlerArgs as regular handlers and can return a state name to immediately transition again (a “bounce”).

_onExit: called when the FSM leaves that state. It receives the same HandlerArgs. Because machina ignores its return value, _onExit cannot start a transition.

yellow: {
  _onEnter({ ctx, emit }) {
    emit("warning-started");
    ctx.warningStartedAt = Date.now();
    // return "red" here to bounce immediately to red
  },
  _onExit({ ctx }) {
    ctx.warningDuration = Date.now() - ctx.warningStartedAt;
    // returning a state name here has no effect
  },
  timeout: "red",
}

onDispose: a config-level hook, declared at the top level beside states. dispose() calls it once per live client before it clears listeners and disposes child FSMs. It receives ctx and emit only. See Disposal.


Inside a handler, calling defer() queues the current input for replay after the next transition. This is useful when a signal arrives before the FSM is ready for it.

initializing: {
  start({ defer }) {
    defer({ until: "ready" }); // replay "start" when we enter "ready"
  },
}

Without { until }, the input replays on the next transition to any state.


dispose() permanently shuts down an FSM. All subsequent method calls become silent no-ops, without errors or events.

light.dispose();
light.handle("timeout"); // no-op
light.currentState(); // no-op (returns last known state)

By default, disposal cascades to child FSMs declared via _child. Pass { preserveChildren: true } to prevent this:

light.dispose({ preserveChildren: true });

Once disposed, an FSM cannot be restarted. Create a new one.

Resource lifetime decides where cleanup goes. A retry timer that lasts as long as one state belongs in that state’s _onExit. A webcam stream or a socket that lasts as long as the FSM belongs in the config-level onDispose hook:

const camera = createFsm({
    id: "camera",
    initialState: "init",
    context: { stream: null as MediaStream | null, closed: false },
    onDispose({ ctx }) {
        ctx.closed = true;
        ctx.stream?.getTracks().forEach(track => track.stop());
        ctx.stream = null;
    },
    states: {/* ... */},
});

dispose() calls the hook once per live client, after it sets the disposed flag and before it clears listeners or disposes children. Inside the hook, handle(), transition(), and reset() are silent no-ops, and emit() still reaches subscribers. The hook receives ctx and emit only, because defer() has no meaning during teardown and inputName would always be empty.

The hook is synchronous. A request that is still in flight when dispose() runs cannot be cancelled from the hook. Set a flag on ctx, as closed above, and have the late result check it before it stores anything.

A release that returns a promise outlives dispose(). When a socket flushes on close, or a pool drains, that work is still in flight after dispose() returns, and a process.exit() on the next line cuts it off. The hook starts the release, and the code around the FSM awaits it:

const pending: Promise<unknown>[] = [];

const conn = createFsm({
    // ...
    onDispose({ ctx }) {
        if (ctx.socket) {
            pending.push(ctx.socket.end().catch(logShutdownError));
            ctx.socket = null;
        }
    },
});

// shutdown
conn.dispose();
await Promise.allSettled(pending);
process.exit(0);

For a BehavioralFsm the array fills once per client, and one allSettled covers them all. Attach the catch inside the hook: AggregateError collects synchronous throws only, and a rejected promise from the hook surfaces as an unhandled rejection with no FSM id attached.

The hook must tolerate a resource that was already released. A terminal state’s _onEnter can release the same resource before dispose() runs, and the hook still runs for that client.

dispose() does not run _onExit on the current state. When a state-scoped resource must also release on teardown, transition to a terminal state first and then call dispose().

A throwing hook does not stop teardown. dispose() runs every remaining hook and finishes its own cleanup. It then throws one AggregateError that lists every failure.

An _onExit handler, an _onEnter handler, or a transitioning or transitioned listener can call dispose(). That callback runs to completion. The engine then skips the rest of the transition, including the next hook, the child reset, the deferred replay, and any bounce. The same rule applies inside handle(). A handling listener that disposes the FSM stops the announced handler from running, and an initial _onEnter that disposes a BehavioralFsm during lazy client initialization stops the handle() or transition() call that triggered it.