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.
Synchronous by Design
Section titled “Synchronous by Design”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.
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.
States
Section titled “States”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.
"green", "yellow", and "red" are your state names. TypeScript knows them at compile time.
Inputs
Section titled “Inputs”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.
If the current state has no handler for that input name and no wildcard handler, machina fires a nohandler event and moves on.
Handlers
Section titled “Handlers”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.
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"; }.
Wildcard handler: "*" matches any input that has no handler of its own in that state. Use inputName to identify the input.
Transitions
Section titled “Transitions”When a handler returns a state name (or a string shorthand resolves), machina runs the transition sequence:
- Calls
_onExiton the current state (if defined) - Emits a
transitioningevent:{ fromState, toState } - Updates the current state
- Calls
_onEnteron the new state (if defined) - Emits a
transitionedevent:{ fromState, toState } - 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
Section titled “Context”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:
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.
Lifecycle Hooks
Section titled “Lifecycle Hooks”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.
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.
Deferred Inputs
Section titled “Deferred Inputs”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.
Without { until }, the input replays on the next transition to any state.
Disposal
Section titled “Disposal”dispose() permanently shuts down an FSM. All subsequent method calls become silent no-ops, without errors or events.
By default, disposal cascades to child FSMs declared via _child. Pass { preserveChildren: true } to prevent this:
Once disposed, an FSM cannot be restarted. Create a new one.
Releasing resources
Section titled “Releasing resources”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:
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:
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.
Disposal inside a callback
Section titled “Disposal inside a callback”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.