Hierarchical States
Overview
Section titled “Overview”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.
Setting up a child
Section titled “Setting up a child”Assign any createFsm (or createBehavioralFsm) instance to the _child property of a state definition. The parent delegates to it automatically — no other wiring required.
Input delegation
Section titled “Input delegation”When handle() is called on the parent, the dispatch order is:
- Input arrives at the parent via
handle() - Parent checks if the current state has a
_child - If yes, parent calls
canHandle()on the child — andcanHandle()answers for the child’s whole_childchain, not just the child itself, so a grandchild’s input counts - 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 - 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.
Declaring bubbled inputs
Section titled “Declaring bubbled inputs”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.
Declaring a bubble does two things:
- It joins the FSM’s own typed input union, so the self-directed
fsm.handle("phaseComplete")call above type-checks without a cast. - It becomes part of the FSM’s mounting contract. Any config that mounts
phaseControllervia_childmust satisfy it — otherwise the mount is a compile error:
Fixing it means doing one of three things:
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()
Section titled “compositeState()”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".
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".
Child auto-reset
Section titled “Child auto-reset”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.
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").
Disposal
Section titled “Disposal”dispose() on the parent cascades to child FSMs by default. If the same child FSM appears in multiple states, it is only disposed once.
Pass { preserveChildren: true } to skip child disposal and keep the child FSM running independently:
Full example
Section titled “Full example”The uploader above, assembled and exercised:
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.