Graph
A graph journey moves through named events. Each event can have one transition candidate or an ordered list of guarded candidates.
Define a graph journey
Section titled “Define a graph journey”import { createGraphJourney } from "@rxova/journey-core";
type Event = { type: "SUBMIT"; payload: { email: string } } | { type: "APPROVE" } | { type: "EDIT" };
const machine = createGraphJourney<{ valid: boolean }, "form" | "review" | "done", Event>({ initial: "form", context: { valid: false }, steps: { form: { on: { SUBMIT: [{ to: "review", when: ({ context }) => context.valid }] } }, review: { on: { APPROVE: "done", EDIT: "form" } }, done: {} }});Send typed events
Section titled “Send typed events”send takes an event type and, when the declared event has one, its payload as a second argument.
await waitUntilSettled(machine);
await machine.send("SUBMIT", { email: "ada@example.com" });await machine.send("APPROVE");The Quickstart defines waitUntilSettled. It waits for initial entry work;
without that wait an immediate send can correctly return reason: "transitioning".
The first candidate whose from matches the current step and whose guard returns true wins. If no
candidate is enabled, send returns { ok: false, reason: "no-enabled-transition" }.
Guards and handlers
Section titled “Guards and handlers”Guards are synchronous and pure because the runtime also evaluates them while deriving graph transition introspection.
const definition = { initial: "form" as const, context: { role: "member" }, handlers: { canApprove: (role: string) => role === "admin" }, steps: { form: { on: { APPROVE: [ { to: "done", when: ({ context, handlers }) => handlers.canApprove(context.role) } ] } }, done: {} }};
const testMachine = createGraphJourney(definition, { handlers: { canApprove: () => true }});Creation options can replace definition handlers, which keeps one definition reusable in tests.
Transactional sends: event work
Section titled “Transactional sends: event work”An event can carry the async that decides its own outcome. The object form pairs a run/commit
with the candidates that route on what commit staged — so the call site stays a bare send, and
the definition owns both the async and the routing:
const cart = { on: { CHECKOUT: { run: ({ snapshot, handlers }) => handlers.api.charge(snapshot.context.items), commit: ({ result, updateContext }) => updateContext((context) => ({ ...context, error: result.charged ? null : "Charge failed." })), candidates: [ { to: "receipt", when: ({ context }) => context.error === null }, // Unguarded last: a failed charge still routes (back here), so its // outcome commits instead of being rolled back. { to: "cart" } ] } }};Work is keyed by (step, event): two steps can declare the same event with different work and
different candidates.
Guards read context and handlers — never the run result directly. A routing fact goes through
commit into the staged context, and the candidates decide from there. That keeps guards total
functions of context, which is what lets snapshot introspection (outgoingTransitions,
availableEvents) report the same answer a live send would.
A work send is a transaction. The exact order:
runexecutes while the machine holds the current step;snapshot.transitionreports the"working"phase and no destination yet.commitreceivesrun’s result. ItsupdateContextwrites to a staged copy of the context, not the live one.- The candidates are evaluated against the staged context, in declaration order. The first enabled candidate wins.
- If no candidate is enabled, the staged context is discarded and
sendreturns{ ok: false, reason: "no-enabled-transition" }. Either the send routed and committed, or neither happened — a work send never half-lands.
Rule 4 has a practical consequence, the totality rule: any outcome that must persist needs an
enabled candidate to carry it. A success outcome routes forward; a failure outcome that should keep
its staged context (an error message, an attempt counter) needs a fallback candidate. stay() is
the named form of that fallback: an unguarded candidate back at the current step. Without one, a
failed run’s staged context is rolled back with the unmatched send — which is why the builder warns
at build time when every candidate of a work declaration is guarded. An intentionally partial event
declares allowRollback: true on the work to silence it.
Three follow-ups worth knowing:
- A self-transition (including
stay()) is an ordinary move. There is nofrom === tospecial case: the step’sonLeaveandonEnterboth run again,onTransitionfires, and the step’s visit count increments. onTransitionruns after the destination commits (see the next section) — by then the staged context is the context.- Snapshot introspection evaluates guards outside any send, so a guard that reads
resultsees it asundefinedthere — details on the snapshot page.
Transition and step effects
Section titled “Transition and step effects”onTransition runs after the destination commits and before the destination step’s onEnter.
Neither can cancel the committed move.
form: { on: { SUBMIT: [ { to: "review", onTransition: async ({ event, snapshot, updateContext, raise }) => { updateContext((context) => ({ ...context, email: event?.payload.email ?? "" })); raise({ type: "APPROVE" }); } } ]; }}Raised events run FIFO after the current transition fully settles. Long cascades are capped by
MAX_RAISED_EVENTS and reported through the error subscription event.
Navigation helpers
Section titled “Navigation helpers”Graph machines still expose navigate:
- timeline back/forward navigation retraces realized history without transition gating;
goToStepById(id)succeeds only when an enabled outgoing transition targetsid;goToNextStep()only moves forward through existing timeline history. It does not choose an arbitrary graph edge.
Step onLeave still runs for all of these moves.
Graph snapshot fields
Section titled “Graph snapshot fields”const snapshot = machine.getSnapshot();
snapshot.type; // "graph"snapshot.declaredEvents; // all event names declared from the current stepsnapshot.availableEvents; // enabled event names from the current stepsnapshot.availableSteps; // enabled target ids from the current stepsnapshot.outgoingTransitions; // every candidate with priority and evaluated guard statesnapshot.currentStep?.isTerminal; // no outgoing transitions are declaredsnapshot.steps.totalSteps;snapshot.steps.visitedStepCount;outgoingTransitions keeps guarded-out candidates visible. Each descriptor contains event, to,
priority, guard ("none", "passed", or "failed"), enabled, and selected. selected marks
the first enabled candidate that send(event) would choose; a later candidate may be enabled but
not selected. The snapshot exposes evaluated state only, never the guard function.