Skip to content

createBehavioralFsm

createBehavioralFsm defines a set of states and transitions once, then applies that behavior to any number of independent client objects. Per-client state is tracked in a WeakMap — nothing is stamped onto the client object itself. The client IS the context; every handler receives it as ctx.

Use createBehavioralFsm when:

  • You have many instances that share the same state machine logic — network connections, game entities, UI components
  • You don’t want the FSM to own or modify the client object’s shape
  • The client already exists and you want to layer FSM behavior onto it without touching its structure

Use createFsm when you have a single instance with its own dedicated context object.

The config is the same as createFsm with one difference: there’s no context property. The client object IS the context. Because the client type can’t be inferred from the config, you provide it via a curried call: createBehavioralFsm<Connection>() fixes the client type, and the function it returns infers everything else from the config you pass it:

const connFsm = createBehavioralFsm<Connection>()({
    id: "connectivity",
    initialState: "disconnected",
    states: { ... },
});

State names, input names, and transition target validation all work identically — inferred from the states config at compile time.

Every method takes the client object as its first argument. The rest of the API mirrors Fsm.

MethodDescription
handle(client, inputName, ...args)Dispatch an input to the client’s current state handler
canHandle(client, inputName)True if the client’s current state — or its _child chain — can handle this input
transition(client, toState)Directly transition the client; fires _onExit, _onEnter, events
reset(client)Transition the client back to initialState
currentState(client)Returns the client’s current state, or undefined if never initialized
compositeState(client)Dot-delimited path including active child FSM states
rehydrate(client, compositeState)Silently place a client at a state — no lifecycle hooks or events
dehydrate(client)Snapshot everything tracked for a client — state, deferrals, children
rehydrate(client, snapshot)Restore a dehydrate() snapshot — state and deferred queues, no hooks
on(eventName, callback)Subscribe to a lifecycle event — returns { off() }
emit(eventName, data?)Emit a custom event to subscribers
dispose()Permanently shut down the entire FSM; cascades to child FSMs by default

Client state is tracked in a WeakMap<TClient, ClientMeta> inside the FSM instance — no properties are added to the client object. When a client is garbage collected, its state entry goes with it automatically. You don’t need to clean up.

First contact with a new client (via handle(), transition(), or reset()) runs the full initialization: transitions into initialState, fires _onEnter, emits lifecycle events. A client the FSM has never seen is transparently initialized on the first call.

The behavioral pattern naturally supports a cold-resume workflow: serialize the client, store it externally (database, file, message queue), and later feed it back to the FSM. The client is just data — the FSM behavior is stateless.

The problem is placement. When a new process tries to use a deserialized client, handle() and transition() both trigger initialization at initialState first — _onEnter fires, events emit, handlers run for states the client should never visit during resume.

rehydrate() solves this. It writes the client directly into the FSM’s internal tracking at the specified state with no lifecycle activity — no _onEnter, no _onExit, no events.

// ---- Persist ----
const snapshot = {
    client: theClient,
    state: connFsm.compositeState(theClient), // "connecting"
};
await db.save(JSON.stringify(snapshot));

// ---- Restore (possibly a different process) ----
const { client, state } = JSON.parse(await db.load());
connFsm.rehydrate(client, state); // silent placement
connFsm.handle(client, "retry"); // proceeds from "connecting"

For hierarchical FSMs, pass the full dot-delimited path from compositeState(). rehydrate() walks the _child chain and places the client at each level:

parentFsm.rehydrate(client, "active.uploading.retrying");
// Parent at "active", child at "uploading", grandchild at "retrying"
// No _onEnter at any level. No events.

rehydrate() throws synchronously on invalid state names or structural mismatches (missing _child, Fsm children). Writes are atomic — if validation fails at any level of a composite path, no state is written at any level.

The string form above only captures the composite state — it drops anything sitting in a client’s deferred queue. If a client can have pending deferrals when it gets persisted, use dehydrate() and the object form of rehydrate() instead. dehydrate() returns a plain, JSON-serializable snapshot of everything machina tracks for a client — current state, pending deferred inputs (with their args and until targets), and the same for every _child in the hierarchy, active or not:

// ---- Persist ----
const snapshot = connFsm.dehydrate(theClient);
// { state: "connecting", deferred: [{ inputName: "retry", args: [], untilState: "online" }] }
await db.save(JSON.stringify(snapshot));

// ---- Restore (possibly a different process) ----
const snapshot = JSON.parse(await db.load());
connFsm.rehydrate(theClient, snapshot); // silent placement, deferrals requeued
connFsm.handle(theClient, "connected"); // "retry" replays once "online" is reached

dehydrate() returns undefined for a client the FSM has never seen — it does not trigger initialization, mirroring currentState(). It throws if any deferred input’s arguments contain something that can’t survive a serialization boundary (a function, undefined, a Date/Map/class instance, a symbol, NaN/Infinity, or a circular reference) — the error names the exact input, its until target if any, and the precise path to the offending value.

Both the string and object forms of rehydrate() place the client silently — no _onEnter, no _onExit, no transitioning/transitioned events — and both validate the entire hierarchy before writing anything, at any level. The object form additionally requeues each level’s deferred inputs so they replay exactly as they would have if the client had never left memory. For hierarchical FSMs, dehydrate() walks every state’s _child recursively, including states the client currently isn’t in — a child left mid-flight when the parent moved on is captured too, so its pending _onExit/_onEnter and deferred replay behavior on the parent’s next re-entry survives the round trip. See Persisting clients for the full repository/service pattern.

BehavioralFsm emits the same lifecycle events as Fsm. The difference is every payload includes a client field so you can identify which client the event pertains to:

EventPayloadFired when
transitioning{ fromState, toState, client }A transition is about to occur
transitioned{ fromState, toState, client }A transition completed
handling{ inputName, client }An input is about to be dispatched
handled{ inputName, client }An input was successfully handled
nohandler{ inputName, args, client }No handler found in current state
invalidstate{ stateName, client }Transition targeted a nonexistent state
deferred{ inputName, client }An input was deferred
connFsm.on("transitioned", ({ fromState, toState, client }) => {
    console.log(`[${client.url}] ${fromState} -> ${toState}`);
});
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";
            },
        },
    },
});

// Two completely independent clients, one FSM definition
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"); // connB retries++, back to disconnected

connFsm.currentState(connA); // "connecting"
connFsm.currentState(connB); // "disconnected"

// Subscribe — client field tells you which one fired
connFsm.on("transitioned", ({ fromState, toState, client }) => {
    console.log(`[${client.url}] ${fromState} -> ${toState}`);
});

// Reset a single client to initialState
connFsm.reset(connA);

// Tear down the whole FSM when done
connFsm.dispose();

The client type is the only thing you need to supply explicitly — via the curried call, createBehavioralFsm<Connection>(). State names and input names are inferred from the states config the same way createFsm handles it.

import type { BehavioralFsmEventMap, HandlerArgs } from "machina";

// Handler args are typed to your client
type ConnHandlerArgs = HandlerArgs<Connection>;

// Event payloads are typed to your client and state names
type ConnEvents = BehavioralFsmEventMap<
    Connection,
    "disconnected" | "connecting" | "online" | "error"
>;

String shorthand transition targets are validated against actual state keys at compile time. A typo like connect: "conecting" is a type error.

There’s a third, rarely-needed call form: supplying both the client type and an already-resolved states type explicitly — createBehavioralFsm<Connection, typeof connectivityStates>({ ... }). It skips inference entirely, which is why it predates (and was never affected by) the curried form’s introduction.