Rxova
Skip to content

Transitions syntax

Linear order and graph events are separate definition forms.

const definition = {
context: { valid: false },
steps: [
"intro",
{
id: "form",
metadata: { title: "Form" },
onLeave: ({ snapshot }) => analytics.track("form_left", snapshot.context)
},
{
id: "done",
onEnter: ({ snapshot }) => report(snapshot.context)
}
] as const
};

The array order controls initial selection, next-step fallback, and linear snapshot indices. A bare string is shorthand for { id, metadata: {} }.

A graph step declares its own outgoing transitions under on, keyed by event. There is no from: the step that declares an entry is the source.

const definition = {
initial: "form" as const,
context: { valid: false },
steps: {
form: {
on: {
SUBMIT: [{ to: "review", when: ({ context }) => context.valid }, { to: "form" }]
}
},
review: {
on: {
APPROVE: [{ to: "done", onTransition: ({ raise }) => raise({ type: "AUDIT" }) }]
}
},
done: {}
}
};

One event maps to one of three things, told apart at the top level — nothing has to look inside a candidate to know which it is:

FormMeaning
SUBMIT: "review"One unguarded candidate. The shorthand.
SUBMIT: [{ to, … }, …]Ordered candidates; the first enabled one wins.
SUBMIT: { run, commit?, candidates }Async the machine owns, routed by the staged context.

A guarded lone candidate is written [{ to, when }]. The extra brackets are deliberate: a guarded lone candidate means the event can fail, and the array form is where that reads honestly.

Candidate fieldPurpose
toRequired destination step. Self-transitions are allowed.
whenOptional synchronous guard receiving { context, handlers }.
onTransitionOptional post-commit effect receiving hook arguments.
labelOptional name for this edge, used wherever the edge has to be named.
timeoutMsOptional budget for this edge’s onTransition, over the global default.

Declaration order is priority order, per step and then per event. The same event may be declared from several steps; candidates are matched against the current step before order is consulted, so those declarations never interfere.

Several candidates on one event differ only by guard, so event and to do not identify which one fired. label does:

on: {
PAY: [
{ to: "review", label: "needs-review", when: ({ context }) => context.tier === "free" },
{ to: "review", label: "flagged", when: ({ context }) => context.flagged },
{ to: "done", label: "straight-through" }
];
}

The name then shows up in three places: timeout and error messages (onTransition(needs-review) timed out after 5000ms), the transition argument every step hook receives, and the structure view plugins and analyzeStructure read.

Labels are optional. An unlabelled edge is described by its declaration index instead — PAY[1] (checkout -> review) — and reports label: null with its index.

defaultTimeoutMs on the factory is the fallback, not the only dial. An edge that calls something slow declares its own budget, and the rest of the graph keeps the tighter default:

const machine = createGraphJourney(definition, { defaultTimeoutMs: 2_000 });
// ...in the definition:
on: {
SUBMIT: {
run: ({ handlers }) => handlers.creditCheck(), // third party, occasionally slow
label: "credit-check",
timeoutMs: 30_000,
candidates: [{ to: "approved" }, { to: "declined" }]
}
}

timeoutMs on a work entry bounds its run; on a candidate it bounds that candidate’s onTransition. Caller-supplied navigation work takes it too: goToNextStep({ run, timeoutMs: 30_000 }). It must be a finite number greater than zero, checked when the definition is built rather than when the timer first matters.

Factories reject empty definitions, duplicate linear ids, unknown graph initial steps, and transitions pointing at undeclared steps. A source step cannot be undeclared — it is the key the entry is written under. Graph guards that throw are treated as disabled.