Skip to content

Hierarchical States

Any state can own a child FSM by setting _child to an FSM instance. While the parent is in that state, inputs dispatched via handle() are checked against the child first using canHandle(), which looks through the child’s entire _child chain. If anything down the chain can handle the input, it’s forwarded. If not, the parent’s own state handlers get it. If the child itself sends an input it can’t handle (e.g. from _onEnter), that fires nohandler on the child and bubbles up to the parent.

Assign any createFsm (or createBehavioralFsm) instance to the _child property of a state definition. The parent delegates to it automatically — no other wiring required.

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, // inputs go here first
            cancel: "idle", // "cancel" isn't on childFsm, so it bubbles here
        },
    },
});

When handle() is called on the parent, the dispatch order is:

  1. Input arrives at the parent via handle()
  2. Parent checks if the current state has a _child
  3. If yes, parent calls canHandle() on the child — and canHandle() answers for the child’s whole _child chain, not just the child itself, so a grandchild’s input counts
  4. If anything down the chain can handle it, the input is forwarded to the child’s handle(), which repeats the same dispatch one level down — until it reaches the FSM that actually handles it
  5. If nothing down the chain can handle it, the parent’s own state handlers get the input

The descendant chain gets first shot, and the parent’s handlers act as a fallback — the same child-first precedence at every level. Delegation reaches as deep as your hierarchy nests (matching compositeState()’s reach), so an input handled three levels down can be dispatched from the root without any manual forwarding. Separately, if the child sends itself an input it can’t handle (e.g. inside _onEnter), that fires nohandler on the child and bubbles up to the parent — this is how the traffic intersection’s phaseComplete input reaches the parent from the child’s red state.

The nohandler-bubbling mechanism above works whether or not the child says anything about it — but an undeclared bubble gives you zero compile-time signal that a container needs to catch it. bubbles closes that gap: it’s a config property listing the inputs an FSM fires at itself without handling, so TypeScript can enforce that whatever mounts it via _child actually deals with them.

// `fsm` is declared before createFsm() and assigned after, so _onEnter can
// close over it safely — the same pattern the traffic intersection example
// uses (setTimeout runs well after createFsm() returns and the assignment
// completes).
let fsm: ReturnType<typeof createFsm>;

const phaseController = createFsm({
    id: "phase-controller",
    initialState: "green",
    bubbles: ["phaseComplete"], // "I fire this; whoever mounts me must deal with it"
    states: {
        green: { advance: "red" },
        red: {
            _onEnter() {
                // Not handled here — this is exactly the self-directed
                // dispatch the nohandler-bubbling mechanism above describes.
                setTimeout(() => fsm.handle("phaseComplete"), 0);
            },
        },
    },
});

fsm = phaseController;

Declaring a bubble does two things:

  1. It joins the FSM’s own typed input union, so the self-directed fsm.handle("phaseComplete") call above type-checks without a cast.
  2. It becomes part of the FSM’s mounting contract. Any config that mounts phaseController via _child must satisfy it — otherwise the mount is a compile error:
// Compile error on `_child` — "phaseComplete" is declared but neither
// handled here, re-declared, nor covered by a catch-all.
const intersection = createFsm({
    id: "intersection",
    initialState: "northSouth",
    states: {
        northSouth: { _child: phaseController },
        clearance: { advance: "northSouth" },
    },
});

Fixing it means doing one of three things:

// 1. Handle it directly — anywhere in the config, not necessarily on the
//    same state that declares `_child`. Coverage is FSM-wide because a
//    bubble re-dispatches against whatever state the parent is in when it
//    fires, which can be any state.
const intersection = createFsm({
    id: "intersection",
    initialState: "northSouth",
    states: {
        northSouth: {
            _child: phaseController,
            phaseComplete: "clearance",
        },
        clearance: { advance: "northSouth" },
    },
});

// 2. Re-declare it in your OWN `bubbles` — this discharges the mount but
//    passes the obligation up one level: whatever mounts `intersection`
//    now owes `phaseComplete` too.
const intersection2 = createFsm({
    id: "intersection-passthrough",
    initialState: "northSouth",
    bubbles: ["phaseComplete"],
    states: {
        northSouth: { _child: phaseController },
        clearance: { advance: "northSouth" },
    },
});

// 3. Carry a "*" catch-all anywhere in the config — it absorbs every
//    bubble along with everything else.
const intersection3 = createFsm({
    id: "intersection-catchall",
    initialState: "northSouth",
    states: {
        northSouth: {
            _child: phaseController,
            "*"({ inputName }) {
                console.log("unhandled:", inputName);
            },
        },
        clearance: { advance: "northSouth" },
    },
});

The contract composes across arbitrarily many hierarchy levels — a grandparent that mounts a re-declaring parent inherits the same obligation, and so on up the chain. An FSM that declares no bubbles (the default) owes nothing and can be mounted anywhere with no obligation at all.

See the Traffic Intersection example for bubbles in a real hierarchy — the phase controller declares bubbles: ["phaseComplete"], and the intersection parent covers it with a handler on each phase state.

compositeState() returns the full state path as a dot-delimited string, walking down through active child FSMs. If the parent is in "active" and the child is in "uploading", you get "active.uploading".

uploader.handle("start");
uploader.compositeState(); // "active.preparing"

uploader.handle("ready");
uploader.compositeState(); // "active.uploading"

uploader.handle("done");
uploader.compositeState(); // "active.verifying"

This is useful for driving UIs from a single string — one compositeState() call tells you the full picture without interrogating multiple FSMs.

Nesting is unbounded. A child can itself have a _child, and compositeState() walks the whole chain: "stateA.stateB.stateC".

When the parent transitions into a state that owns _child, machina automatically calls reset() on the child, returning it to its initialState. This happens after _onEnter and the transitioned event, but before deferred queue processing.

uploader.handle("start");
uploader.compositeState(); // "active.preparing"

uploader.handle("ready");
uploader.compositeState(); // "active.uploading"

// Cancel drops back to idle, then...
uploader.handle("cancel");
uploader.compositeState(); // "idle"

// Re-entering "active" auto-resets childFsm back to "preparing"
uploader.handle("start");
uploader.compositeState(); // "active.preparing" — fresh start

Re-entering a parent state always starts the child fresh. To restore a client at a specific point in a child hierarchy without triggering lifecycle hooks, use rehydrate() with a composite dot-path — e.g. fsm.rehydrate(client, "active.uploading").

dispose() on the parent cascades to child FSMs by default. If the same child FSM appears in multiple states, it is only disposed once.

uploader.dispose();
// childFsm is also disposed — all method calls become silent no-ops

Pass { preserveChildren: true } to skip child disposal and keep the child FSM running independently:

uploader.dispose({ preserveChildren: true });
// childFsm is still alive

The uploader above, assembled and exercised:

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,
            cancel: "idle",
        },
    },
});

uploader.handle("start");
uploader.compositeState(); // "active.preparing"

uploader.handle("ready");
uploader.compositeState(); // "active.uploading"

uploader.handle("done");
uploader.compositeState(); // "active.verifying"

uploader.handle("verified");
uploader.compositeState(); // "active.complete"

// "cancel" has no handler on childFsm — bubbles to parent
uploader.handle("cancel");
uploader.compositeState(); // "idle"

// Re-start: child resets automatically to "preparing"
uploader.handle("start");
uploader.compositeState(); // "active.preparing"

For a more complex real-world case — two independent child FSM instances, input bubbling across phases, defer() for pedestrian requests, and child auto-reset driving a full traffic signal cycle — see the Traffic Intersection example.