Skip to content

createFsm

createFsm is the standard choice for most use cases. One config object produces one FSM instance with one internal context object (there’s no separate client to pass around). Handler signatures receive ({ ctx, inputName, defer, emit }, ...args) as a plain destructurable object, so arrow functions work without caveats and there’s no this to think about. Return a state name to transition, or return nothing to stay put.

If you need one FSM definition to manage many independent client objects, see createBehavioralFsm.

createFsm({
    id: "traffic-light",       // identifier used in lifecycle event payloads
    initialState: "green",     // must match a key in states
    context: { tickCount: 0 }, // mutable data object passed to handlers as ctx
    states: { ... },           // map of state names to handler definitions
});
Property Type Description
id string Identifier for this FSM. Appears in lifecycle event payloads.
initialState string The state the FSM boots into. Must match a key in states.
context object Mutable data scoped to this FSM. Passed to every handler as ctx. Omit for context-free FSMs.
onDispose function Optional teardown hook. dispose() calls it once with { ctx, emit }. See dispose.
states object Map of state names to handler definitions. Keys become the state name union.

Within each state, the following keys have special meaning:

Key Type Description
_onEnter HandlerFn Lifecycle hook. Called when entering this state.
_onExit HandlerFn Lifecycle hook. Called when exiting this state.
_child Fsm | BehavioralFsm Child FSM. Inputs are delegated here first, and unhandled inputs bubble up.
"*" HandlerFn Catch-all. Handles any input without its own handler.
anything else string | HandlerFn Named input handler. String shorthand always transitions, and a function returns a state name or void.

Every handler (a named input handler, _onEnter, _onExit, or the catch-all) receives the same args object as its first parameter:

states: {
    green: {
        // Full destructuring
        tick({ ctx, inputName, defer, emit }, ...args) {
            ctx.tickCount++;
        },

        // Destructure only what you need
        timeout({ ctx }) {
            if (ctx.tickCount >= 5) {
                return "yellow";
            }
        },

        // Arrow functions work fine
        _onEnter: ({ ctx }) => {
            ctx.tickCount = 0;
        },
    },
}
Parameter Description
ctx The context object from the config. Mutate it freely.
inputName The name of the input currently being handled. Useful in catch-all handlers.
defer Queues the current input for replay after the next transition. See Deferred Input.
emit Emits a custom event through the FSM’s event emitter.
...args Additional arguments passed through handle("inputName", arg1, arg2).

Handlers return a state name to transition or void/undefined to stay in the current state. A returned state name and string shorthand express the same transition in two forms.

Method Description
handle(inputName, ...args) Dispatch an input to the current state’s handler.
canHandle(inputName) true if the current state has a handler (or "*") for this input, or if the current state’s _child chain can handle it.
transition(toState) Directly transition; fires _onExit, _onEnter, and lifecycle events. Same-state transitions are silently ignored.
reset() Transition back to initialState. Fires _onEnter as if entering fresh.
currentState() Returns the current state name.
compositeState() Dot-delimited path including active child FSM states (e.g. "active.uploading.retrying"). Returns just the current state when no child is active.
on(eventName, callback) Subscribe to a lifecycle event. Returns { off() }.
emit(eventName, data?) Emit a custom event through the FSM to all on() subscribers.
dispose(options?) Permanently shut down. All subsequent calls are silent no-ops. Cascades to child FSMs unless { preserveChildren: true } is passed.
// Basic dispatch
light.handle("timeout");

// With extra args, received as ...args in the handler
fsm.handle("success", responseData);

Extra args are untyped by default. To type them per input, and have handle() reject a missing or malformed payload at compile time, declare an input payload map.

This method is used internally by machina when dealing with hierarchical FSMs. It’s useful for conditionally triggering inputs without swallowing nohandler events (since the parent will ask the child FSM “can you handle this input?”). When the current state mounts a child FSM, canHandle() answers for the whole _child chain, the same depth that handle()’s delegation covers:

if (light.canHandle("timeout")) {
    light.handle("timeout");
}

Returns false after dispose().

For transitioning from outside a handler (a timer, an event listener, anything external to the FSM):

// Fires _onExit on current state, _onEnter on "red", emits transitioning/transitioned
light.transition("red");

Prefer returning a state name from inside handlers over calling transition() there. The transition logic then stays declarative and co-located with the state.

Subscribe to built-in lifecycle events or custom ones:

// Typed payload — fromState/toState are narrowed to your actual state names
const sub = light.on("transitioned", ({ fromState, toState }) => {
    console.log(`${fromState} -> ${toState}`);
});

sub.off(); // unsubscribe

// Wildcard — catches every event, named and custom
light.on("*", (eventName, data) => {
    console.log(eventName, data);
});

// Custom event from inside a handler
states: {
    green: {
        tick({ ctx, emit }) {
            ctx.tickCount++;
            if (ctx.tickCount % 10 === 0) {
                emit("milestone", { count: ctx.tickCount });
            }
        },
    },
}

// Or from outside the FSM
light.emit("milestone", { count: 42 });

Built-in events emitted by lifecycle:

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 the current state.
invalidstate { stateName } Transition targeted a nonexistent state.
deferred { inputName } An input was deferred.

See Events for the full event reference.

fsm.dispose(); // also disposes child FSMs
fsm.dispose({ preserveChildren: true }); // leave child FSMs running

After dispose(), every method call is a silent no-op. on() returns a no-op Subscription. This is intentional: you don’t need to null-guard callsites just because something may have been torn down.

When the config declares an onDispose hook, dispose() calls it once with { ctx, emit } before it clears listeners and disposes children. Put the release of any resource that lasts as long as the FSM there. A resource that lasts as long as one state belongs in that state’s _onExit, which dispose() does not run.

const camera = createFsm({
    id: "camera",
    initialState: "init",
    context: {
        stream: null as MediaStream | null,
        // getUserMedia() can resolve after dispose(). The hook cannot cancel
        // it, so the late result checks this flag and stops its own tracks.
        closed: false,
    },
    onDispose({ ctx }) {
        ctx.closed = true;
        if (ctx.stream) {
            ctx.stream.getTracks().forEach(track => track.stop());
            ctx.stream = null;
        }
    },
    states: {
        init: {
            _onEnter({ ctx }) {
                navigator.mediaDevices.getUserMedia({ video: true }).then(stream => {
                    if (ctx.closed) {
                        stream.getTracks().forEach(track => track.stop());
                        return;
                    }
                    ctx.stream = stream;
                    camera.handle("ready");
                });
            },
            ready: "streaming",
        },
        streaming: {
            stop: "idle",
        },
        idle: {
            start: "init",
        },
    },
});

A throwing hook does not stop teardown. dispose() finishes its cleanup and then throws one AggregateError whose errors array lists every failure, including failures from child FSMs. The Disposal section of the concepts guide has the full contract.

You don’t pass manual type parameters to createFsm. TypeScript infers the context type from config.context, the state name union from the keys of config.states, and the input name union from the handler keys across all states.

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

// These are compile errors:
light.handle("bogus"); // "bogus" is not a valid input name
light.transition("yellw"); // "yellw" is not a valid state name

String shorthand targets are validated against actual state keys, so timeout: "yellw" is a compile-time type error.

When you need the inferred types explicitly, the StateNamesOf and InputNamesOf utility types extract them from your states config. A function that takes input names is the typical case:

import type { StateNamesOf, InputNamesOf } from "machina";

const config = {
    id: "traffic-light",
    initialState: "green" as const,
    context: { tickCount: 0 },
    states: {
        green: {
            timeout: "yellow" as const,
            tick({ ctx }: any) {
                ctx.tickCount++;
            },
        },
        yellow: { timeout: "red" as const },
        red: { timeout: "green" as const },
    },
} as const;

type States = StateNamesOf<typeof config.states>; // "green" | "yellow" | "red"
type Inputs = InputNamesOf<typeof config.states>; // "timeout" | "tick"

You won’t need these often, because the FSM’s own handle() and transition() methods are already narrowed to the correct literal unions.

A traffic light with tick-based timing, event subscription, and lifecycle hooks:

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: {
            // String shorthand: always transitions to "red", no logic needed
            timeout: "red",
        },
        red: {
            timeout: "green",
        },
    },
});

// Subscribe before driving the FSM
const sub = light.on("transitioned", ({ fromState, toState }) => {
    console.log(`${fromState} -> ${toState}`);
});

// Drive it
for (let i = 0; i < 5; i++) {
    light.handle("tick");
}
light.handle("timeout"); // tickCount is 5, transitions to yellow
// logs: green -> yellow

light.handle("timeout"); // transitions to red
// logs: yellow -> red

light.currentState(); // "red"
light.compositeState(); // "red"

light.canHandle("tick"); // false: red has no "tick" handler

sub.off(); // unsubscribe

light.reset(); // back to green, fires _onEnter (resets tickCount to 0)
light.dispose();