The startProcess Runner
Driving FsmProcess by hand means writing the same wiring every time: construct the process, register onStateCreate, look up the right handler for each state, install onEnter and onExit, and thread a shared context through everything. startProcess does that once so you don’t repeat it. Most consumers use the runner and never touch the raw engine.
The shape
import { startProcess } from "@statewalker/fsm";
const handle = await startProcess(context, config, load, startEvent = "");
You provide three things and get back a handle:
context— a plain object your handlers share. The runner also binds the machine’s controls into it.config— theFsmStateConfig.load(stateKey, event)— called on every state entry; returns the handlers to run for that state.
startProcess creates the process, dispatches startEvent to enter the initial state, and returns a ProcessHandle. The default startEvent is "", the eventless start.
Handlers and their return values
load returns an array of stage handlers. Each handler runs on entry with the shared context, and its return value decides what happens on exit — setup and teardown live in one function.
A handler may return:
Nothing — fire-and-forget behaviour with no cleanup.
(stateKey) => stateKey === "Off" ? [() => console.log("dark")] : [];
A cleanup function — registered as the state’s onExit. This is where you remove listeners, clear timers, or restore state.
(stateKey) => stateKey === "On" ? [(ctx) => {
const id = setInterval(() => console.log("still on"), 1000);
return () => clearInterval(id); // runs when the state exits
}] : [];
A generator — run concurrently; every string it yields is dispatched back into the machine. This is how a state drives itself: watch something, and emit events as it changes. The generator is return()-ed automatically when the state exits, so its finally blocks run.
async function* Ticking() {
while (true) {
await new Promise((r) => setTimeout(r, 1000));
yield "tick"; // dispatched back — may trigger a transition
}
}
(stateKey) => stateKey === "Running" ? [Ticking] : [];
You can return several handlers for one state — for example a controller that reacts to input plus a trigger that emits events — and they all run.
Reaching the machine from a handler
Handlers don’t get a reference to the FsmProcess. Instead the runner binds its controls into the context under exported keys, so every handler reaches the machine the same way, no matter where it is defined:
import { KEY_DISPATCH, KEY_TERMINATE, KEY_STATES, KEY_EVENT } from "@statewalker/fsm";
(stateKey, context) => [
(ctx) => {
const dispatch = ctx[KEY_DISPATCH]; // (event) => Promise<void>, guarded
const states = ctx[KEY_STATES]; // ["Root", …, currentLeaf]
const event = ctx[KEY_EVENT]; // last dispatched event
button.onclick = () => dispatch("toggle");
return () => { button.onclick = null; };
},
];
KEY_DISPATCH is guarded: dispatching an event with no matching transition in the current state is a no-op, so you can wire a button to an event without checking first. KEY_STATES and KEY_EVENT are refreshed on every transition.
A complete example
import { startProcess, KEY_DISPATCH } from "@statewalker/fsm";
const config = {
key: "LightBulb",
transitions: [
["", "*", "Off"],
["Off", "toggle", "On"],
["On", "toggle", "Off"],
],
};
const context = {};
const handle = await startProcess(context, config, (stateKey) => {
if (stateKey === "On") return [() => console.log("💡 on")];
if (stateKey === "Off") return [() => console.log("· off")];
return [];
});
// Drive it:
const dispatch = context[KEY_DISPATCH];
await dispatch("toggle"); // → 💡 on
await dispatch("toggle"); // → · off
// Stop it — unwinds every active state, running cleanups inner-to-outer:
await handle.shutdown();
The handle
startProcess returns a ProcessHandle — your remote control, independent of the underlying process:
shutdown()— exit every active state and end the machine.dump(...args)— snapshot the machine. See Serialization.restore(dump, ...args)— rehydrate a snapshot.
When to drop to the engine
Reach for FsmProcess directly when you need something the load contract doesn’t express — custom error routing per state, driving several independent handler registries over one machine, or building your own runner. Everything startProcess does, it does through the public engine API.