machina
Focused finite state machines for JavaScript and TypeScript. States in, states out.
Full documentation
Install
npm install machina
Quick start — createFsm
createFsm is the standard choice: one config, one FSM instance, one internal context object. Handler signatures receive a { ctx, inputName, defer, emit } args object — no this, works with arrow functions.
Handlers return a state name to transition, or nothing to stay put.
import { createFsm } from "machina";
const light = createFsm({
id: "traffic-light",
initialState: "green",
context: { tickCount: 0 },
states: {
green: {
_onEnter({ ctx }) {
ctx.tickCount = 0;
},
tick({ ctx }) {
ctx.tickCount++;
},
timeout({ ctx }) {
if (ctx.tickCount >= 5) return "yellow";
},
},
yellow: {
timeout: "red", // string shorthand — always transitions
},
red: {
timeout: "green",
},
},
});
light.handle("tick");
light.handle("tick");
light.handle("timeout"); // stays green — tickCount is 2, not >= 5
light.currentState(); // "green"
light.compositeState(); // "green" (dot-delimited path, useful with child FSMs)
light.canHandle("timeout"); // true
light.reset(); // back to initialState, fires _onEnter
light.dispose(); // tears down; all subsequent calls are silent no-ops
Public API — Fsm
| Method | Description |
|---|---|
handle(inputName, ...args) |
Dispatch an input to the current state's handler |
canHandle(inputName) |
True if the current state — or its _child chain — can handle this input |
transition(toState) |
Directly transition; fires _onExit, _onEnter, lifecycle events |
reset() |
Transition back to initialState |
currentState() |
Returns the current state name |
compositeState() |
Dot-delimited path including active child FSM states |
on(eventName, callback) |
Subscribe to a lifecycle event (returns { off() }) |
emit(eventName, data?) |
Emit a custom event |
dispose(options?) |
Permanently shut down; cascades to child FSMs by default |
createBehavioralFsm
createBehavioralFsm defines behavior once and applies it to any number of independent client objects. Per-client state lives in a WeakMap — no properties are stamped onto the client. The client object IS the context; handlers receive it as ctx.
The client type can't be inferred from the config, so it's provided via a curried call — createBehavioralFsm<Connection>() — that fixes the client type before the config (and its literal state/input names) is inferred from the second call.
import { createBehavioralFsm } from "machina";
interface Connection {
url: string;
retries: number;
}
const connFsm = createBehavioralFsm<Connection>()({
id: "connectivity",
initialState: "disconnected",
states: {
disconnected: {
connect: "connecting",
},
connecting: {
connected: "online",
failed({ ctx }) {
ctx.retries++;
if (ctx.retries >= 3) return "error";
return "disconnected";
},
},
online: {
disconnect: "disconnected",
},
error: {
reset({ ctx }) {
ctx.retries = 0;
return "disconnected";
},
},
},
});
const connA = { url: "wss://host-a.example.com", retries: 0 };
const connB = { url: "wss://host-b.example.com", retries: 0 };
connFsm.handle(connA, "connect");
connFsm.handle(connB, "connect");
connFsm.handle(connB, "failed");
connFsm.currentState(connA); // "connecting"
connFsm.currentState(connB); // "disconnected"
BehavioralFsm has the same API as Fsm, except every method takes the client object as its first argument. It also adds rehydrate(client, compositeState) for restoring previously-serialized clients without triggering lifecycle hooks or events.
Rehydrating persisted clients
rehydrate() places a client at a known state with no lifecycle activity — no _onEnter, no events. It accepts the same dot-path format that compositeState() produces, enabling cold-resume workflows where clients are serialized to external storage and later restored.
// Persist
const snapshot = { client: connA, state: connFsm.compositeState(connA) };
// Restore (possibly a different process)
const { client, state } = JSON.parse(stored);
connFsm.rehydrate(client, state); // silent — no _onEnter, no events
connFsm.handle(client, "retry"); // proceeds from restored state
For hierarchical FSMs, pass the dot-delimited path: fsm.rehydrate(client, "active.uploading.retrying"). Throws on invalid states or structural mismatches.
Hierarchical states
Attach a child FSM to any state via _child. Inputs are dispatched to the child first; unhandled inputs bubble up to the parent.
import { createFsm } from "machina";
const childFsm = createFsm({
id: "upload-phases",
initialState: "preparing",
context: {},
states: {
preparing: { ready: "uploading" },
uploading: { done: "verifying" },
verifying: { verified: "complete" },
complete: {},
},
});
const uploader = createFsm({
id: "uploader",
initialState: "idle",
context: {},
states: {
idle: {
start: "active",
},
active: {
_child: childFsm, // delegates inputs here first
cancel: "idle", // "cancel" is not on childFsm, so it bubbles up here
},
},
});
uploader.handle("start");
uploader.compositeState(); // "active.preparing"
uploader.handle("ready");
uploader.compositeState(); // "active.uploading"
uploader.handle("cancel");
uploader.compositeState(); // "idle"
The parent re-enters initialState of the child FSM whenever the parent transitions into the state that owns _child.
Delegation reaches any depth: canHandle() answers for the whole _child chain, so an input handled three levels down can be dispatched from the root. When both a descendant and an ancestor could handle an input, the descendant wins.
An FSM can also declare inputs it fires at itself but never handles — expecting whatever mounts it to catch them via bubbling:
const phase = createFsm({
id: "phase",
initialState: "running",
context: {},
bubbles: ["phaseComplete"], // "I fire this; whoever mounts me must deal with it"
states: {
running: {
_onEnter() {
// ...later: phase.handle("phaseComplete") — typed, no handler here
},
},
},
});
Declared bubbles join the FSM's typed input union, and they form a compile-time contract: a config that mounts phase via _child must handle phaseComplete, re-declare it in its own bubbles, or carry a "*" catch-all — otherwise it fails to compile, with the error naming what's missing. Standalone FSMs owe nothing; the contract only exists where FSMs get wired together. The engine never reads bubbles — it's purely type-level.
Deferred input
Call defer() inside a handler to queue the current input for replay after the next transition. Pass { until: "stateName" } to replay only on entry to a specific state.
const fsm = createFsm({
id: "loader",
initialState: "loading",
context: {},
states: {
loading: {
_onEnter() {
// simulate async load completing
setTimeout(() => fsm.handle("loaded"), 100);
},
// Can't process "save" while still loading — replay it later
save({ defer }) {
defer({ until: "ready" });
},
loaded: "ready",
},
ready: {
save() {
// deferred "save" replays here automatically after transitioning in
console.log("saving");
},
},
},
});
fsm.handle("save"); // deferred — not yet in "ready"
// after "loaded" fires and transitions to "ready", "save" replays automatically
Events
Both Fsm and BehavioralFsm emit lifecycle events you can subscribe to with on(). Use "*" to catch everything.
const sub = light.on("transitioned", ({ fromState, toState }) => {
console.log(`${fromState} -> ${toState}`);
});
sub.off(); // unsubscribe
// wildcard — receives every event
light.on("*", (eventName, data) => {
console.log(eventName, data);
});
Built-in events
| Event | Payload | Fired when |
|---|---|---|
transitioning |
{ fromState, toState } |
A transition is about to occur |
transitioned |
{ fromState, toState } |
A transition completed |
handling |
{ inputName } |
An input is about to be dispatched |
handled |
{ inputName } |
An input was successfully handled |
nohandler |
{ inputName, args } |
No handler found in current state |
invalidstate |
{ stateName } |
Transition targeted a nonexistent state |
deferred |
{ inputName } |
An input was deferred |
BehavioralFsm events include a client field in every payload to identify which client the event pertains to.
TypeScript
machina is written in TypeScript. State names, input names, and handler signatures are all inferred from the config object — no manual type parameters needed for createFsm.
import type {
FsmConfig,
FsmEventMap,
BehavioralFsmEventMap,
HandlerArgs,
HandlerFn,
StateNamesOf,
InputNamesOf,
Subscription,
DisposeOptions,
} from "machina";
// StateNamesOf and InputNamesOf extract literal unions from your states config:
// type MyStates = StateNamesOf<typeof myConfig.states>; // "green" | "yellow" | "red"
// type MyInputs = InputNamesOf<typeof myConfig.states>; // "tick" | "timeout"
Transition targets in string shorthand are validated against actual state keys at compile time. A typo like timeout: "yellw" is a type error.
Disposal
dispose() permanently shuts down an FSM. All subsequent method calls are silent no-ops. Child FSMs are disposed by default.
fsm.dispose(); // also disposes child FSMs
fsm.dispose({ preserveChildren: true }); // leave child FSMs running
Companion tools
- machina-inspect — static analysis for FSM configs: unreachable states,
_onEnterloops, missing handlers. - machina-test — Jest/Vitest custom matchers for testing FSM graph topology. Assert reachability and catch dead states from your test suite.
- eslint-plugin-machina — ESLint plugin wrapping machina-inspect for inline editor feedback.
License
MIT + GPL-2.0