Hierarchy & Lifecycle
- States, Events & Transitions – the base model
- Configuration Schema – the formal config shape
- Getting Started – attach behaviour to states
Flat state machines break down as soon as a process has phases within phases. A media player is “playing,” but inside playing it might be buffering or streaming; on stop, both should unwind cleanly. StateWalker models this directly: a state can contain child states, each with their own transitions, nested to any depth.
The active-state stack
At any moment a running machine holds a stack of active states, from the root down to the current leaf. The leaf is “where you are”; the states above it are the phases that contain it and are still active.
Player ← root, always active
└─ Active ← composite, entered on "play"
└─ Playing ← leaf, the current state
Playing, Active, and Player are all active at once. When you dispatch an event, the engine unwinds the states that exit and enters the states that become active — always leaving the stack resting on a leaf.
Descending into initial children
Transitions between sub-states are declared only in their direct parent. Entering a composite state automatically descends into its initial child — the target of its ["", "*", …] transition — and keeps descending until it reaches a leaf.
const config = {
key: "Player",
transitions: [
["", "*", "Idle"],
["Idle", "play", "Active"],
["*", "stop", "Idle"],
],
states: [
{ key: "Idle" },
{
key: "Active",
transitions: [
["", "*", "Playing"], // initial child of Active
["Playing", "pause", "Paused"],
["Paused", "play", "Playing"],
],
states: [
{ key: "Playing" },
{ key: "Paused" },
],
},
],
};
Dispatching play from Idle enters Active, which immediately descends into its initial child Playing. One event, two states entered — the parent and its initial leaf.
Bubbling unhandled events
When the current leaf has no rule for an event, the engine walks up the parent chain looking for one. That is how an event handled only by an outer state still fires from deep inside.
In the config above, Playing and Paused have no stop rule. Dispatching stop while Playing finds nothing locally, bubbles up to Player’s ["*", "stop", "Idle"], and unwinds both Playing and Active on the way to Idle. You declare the shared exit once, at the level that owns it, and every descendant inherits it.
Enter and exit ordering
Lifecycle hooks run in the order that keeps nesting consistent:
- Entering runs outer-to-inner: a parent’s
onEnterfires before its child’s. By the time a leaf enters, every state that contains it is already set up. - Exiting runs inner-to-outer: the leaf’s
onExitfires before its parent’s. Teardown unwinds in the reverse of setup, so a child never outlives the parent resource it depends on.
process.onStateCreate((state) => {
state.onEnter(() => console.log("enter", state.key));
state.onExit(() => console.log("exit ", state.key));
});
Entering Active → Playing logs enter Active then enter Playing. A later stop logs exit Playing then exit Active. Multiple onExit handlers on the same state also run inner-first relative to their registration, so cleanups compose predictably.
Exiting to the parent
A transition whose target is "" is a final transition: it exits the current state back to its parent, which then resolves its own next transition. This is how a nested sub-process signals “I’m done” and lets the enclosing state decide what comes next — without the child knowing anything about its siblings at the parent level.
transitions: [
["Checkout", "done", ""], // finish Checkout; control returns to the parent
];
Why this matters
Hierarchy keeps each level’s transition table small and local: a state only declares transitions between its own direct children. Shared behaviour — a global stop, an error exit — lives once at the level that owns it and is inherited by everything beneath. The result reads the way you think about the process: a big phase made of smaller ones, each responsible only for its own step.