Skip to content

State machines

A machine is the unit of state in Stator. This page explains what’s inside one and why the model is shaped the way it is. For building one step by step, see the tutorial.

Most frameworks treat “state machines” as a library you reach into from inside a component tree. Stator inverts that: the machine is the canonical state, and everything else — templates, the DOM, the wire — derives from it. There is no second copy of a fact to keep in sync. This inversion is what makes the rest of the framework’s guarantees possible: if the machine is the only source of truth, the server can always compute exactly what a change affects.

Three words recur throughout these docs, one per level of a machine’s life:

  • Definition — what defineMachine returns and what you import: the inert blueprint (MachineDef). Definitions are how machines are addresseddispatch(CartMachine, …), Stator.reads([CartMachine]) — and how their types travel (EventOf, InstanceOf).
  • Actor — a running machine: the engine object that holds a live snapshot and applies transitions. On the server, actors are per-request and transient — created, run, persisted, disposed (see Sessions and state). In a client island, one actor lives as long as its element. The only place you handle an actor directly is a unit test, via createActor.
  • Instance — the typed handle your code actually holds: every selector as a live property, plus send(). Stator.reads hands a route instances; use() hands an island a client instance; InstanceOf<typeof CartMachine> is its type. An instance wraps an actor; you never reach the actor through it.

When these docs say instance, they mean the thing your code holds. Actor appears only when the engine’s runtime is the subject.

defineMachine takes a single config object and returns a definition. The definition is a value you import where you use it — its presence in a .stator frontmatter or <script> is what decides where it runs.

export default defineMachine({
name: 'CartMachine',
lifecycle: 'session',
events: {} as CartEvents,
context: { items: [] },
initial: 'idle',
states: { idle: { on: { /* … */ } } },
selectors: { /* … */ },
})

The whole shape is declarative and statically analyzable — there’s no imperative setup step, which is what lets the compiler reason about a machine and the engine serialize it.

context is the machine’s data — its initial value defines the shape. Each actor gets its own clone; transitions mutate a draft, and the engine clones-and-commits so you write plain mutations without sharing state between actors.

states is the state graph and initial names the entry state. The state path is flat (depth-1): a machine is in exactly one named state, and transitions move between them.

A top-level on: declares handlers that apply in ANY state, consulted only when the current state doesn’t declare the event — a state-scoped handler always wins. It’s the home for a completion event whose handling must not depend on an unrelated machine-wide state: a per-record save settling while the machine is busy reloading a collection would otherwise be silently dropped.

defineMachine({
// …
states: { loading: { /* … */ }, ready: { /* … */ } },
on: {
SAVE_SETTLED: (ctx, ev) => {
ctx.saves[ev.id] = ev.result
},
},
})

You declare a machine’s event surface as a discriminated union and hand it over as a phantom value:

type CartEvents =
| { type: 'ADD_ITEM'; productId: string }
| { type: 'CLEAR' }
// in the config:
events: {} as CartEvents,

The engine only reads its type; {} as CartEvents carries no runtime data. From there, every transition narrows per event:

on: {
ADD_ITEM: [
{
when: (ctx, ev) => !ctx.items.some((i) => i.productId === ev.productId),
do: (ctx, ev) => { ctx.items.push(/* … */) },
emit: 'ITEM_ADDED',
},
{ do: (ctx, ev) => { /* bump quantity */ }, emit: 'ITEM_QUANTITY_CHANGED' },
],
}
  • Actions (do) mutate a draft of context. The engine owns the clone and the commit.
  • Guards (when) make a transition a list of ordered candidates — the first whose when passes wins (a candidate with no when always matches, so it’s the default tail).
  • Emits fire after the action commits, so a payload selector sees the post-transition context. Emits are how one machine announces a fact for others to react to — see Composition.

selectors are pure derived views over context. They’re the read surface: templates call read(machine, sel) against them, on server and client machines alike. A selector that takes an argument returns a function (contains: (ctx) => (id) => …).

Selectors receive the same helpers actions and guards get, so a machine that declares reads: can project a cross-machine verdict as display state:

selectors: {
atCeiling: (ctx, { reads }) => (sku: string) =>
(ctx.lines.find((l) => l.sku === sku)?.qty ?? 0) >= (reads.InventoryMachine.stock[sku] ?? 0),
}

That’s the same rule the ADD guard enforces, projected onto a button: disabled={read(cart, (c) => c.atCeiling(sku))}. The framework knows the dependency (it’s declared), so bindings on the reading machine re-diff when the read machine changes — the verdict can’t go stale.

Purity is over the machine’s declared dependency graph, not just its own context. A selector that uses only ctx runs identically on the server and in a client island; a reads-aware selector server-pins the machine (see Capabilities), same as reads in actions and guards.

Some machines can only run on the server. A machine that reads another machine is server-pinned, because cross-machine reads resolve server-side; a machine with no such dependency is portable and can run in a client island. The classification is computed from the definition (computeCapabilities in engine/define-machine.ts) and carries the reasons, so when you place a server-pinned machine on the client, the compile error can name exactly why.

lifecycle: 'app' | 'session' answers how long an instance lives — one per process, or one per visitor. It does not decide where the machine runs; that’s import location. A session machine is still a server machine. Keeping these two axes separate is deliberate: lifetime is a runtime property, placement is a use-site decision. See The server/client boundary.