Skip to content

What you can build

The quickstart is two steps and a field, which is the wrong size for deciding whether an engine can hold your flow. This page is the other end: a booking that branches on an answer, runs a sub-flow once per traveller, skips a step for children, waits on a seat map it does not have, refuses to leave a step whose card is wrong, and refuses to enter the last step until the card exists - and survives being written out two frames deep and read back into a different engine.

The orchestration is not code you write: the branches, the repeat, the waiting and the two refusals are fields of one JSON object, six steps long. What the object cannot hold, it names - the sub-flow each traveller runs is a second definition of exactly the same kind, and the loader and the validator are two functions in a registry. Those three are yours to write; the order they run in is not.

flow.ts
import { defineFlow, group, step } from '@wizzard-packages/core';
import { empty, eq, get, not } from '@wizzard-packages/core/expr';
/** One traveller on the booking, as the trip step collects them. */
export interface Traveller {
id: string;
name: string;
type: 'adult' | 'child';
}
/**
* The sub-flow a group runs once per traveller.
*
* It is an ordinary definition: it has no idea it is nested, and it reads the
* traveller through `ctx`, which the group's `input` fills. `meal` exists only
* for adults, so the branch is inside the repeat rather than around it.
*/
export const passenger = defineFlow({
id: 'passenger',
version: 1,
order: ['who', 'meal'],
steps: {
// No slice type on either: a step inside a repeat runs once per item, so
// the data it collects is keyed by the item, at a path the app chooses.
who: step({ label: 'Who is travelling' }),
meal: step({ label: 'Meal', when: eq(get('ctx.traveller.type'), 'adult') }),
},
});
/**
* The booking, and the point of the page: branching, a repeat over a list,
* a sub-flow, a step that loads before it is shown and a step guarded against
* being entered early are five fields of one JSON object.
*
* `version` is not decoration. A flow containing a repeat must carry one,
* because a restored snapshot names keys of a list that may have changed shape
* since it was written.
*/
export const booking = defineFlow({
id: 'booking',
version: 1,
order: ['trip', 'people', 'seats', 'visa', 'payment', 'review'],
steps: {
trip: step<{ destination: string; travellers: Traveller[] }>({
label: 'The trip',
}),
// Once per traveller, and not at all while the list is empty - the `when`
// a repeat is required to carry.
people: group({
label: 'Travellers',
flow: 'passenger',
when: not(empty(get('data.trip.travellers'))),
repeat: { over: get('data.trip.travellers'), keyBy: 'id' },
input: { traveller: get('loop.item') },
}),
// The seat map is not in the bundle. The engine waits on entry; where the
// answer lands is the resolver's business.
seats: step<{ row: number }>({
label: 'Seats',
load: { $ref: 'seatMap' },
}),
// A branch on an answer given before the whole group ran.
visa: step<{ number: string }>({
label: 'Visa',
when: eq(get('data.trip.destination'), 'JP'),
}),
payment: step<{ card: string }>({
label: 'Payment',
validate: { $ref: 'cardAccepted' },
}),
// A guard is not a policy: it refuses entry on its own terms, however the
// move that reached it was made.
review: step({
label: 'Review',
guards: { enter: not(empty(get('data.payment.card'))) },
}),
},
policy: 'free',
});

Six fields are doing the work. when on visa is a branch on an answer given four steps earlier. repeat on people turns one sub-flow into one pass per traveller, and keyBy is what makes that survive the list being edited. input hands each pass its own traveller, which is what lets the sub-flow’s own when skip meal for a child without knowing it is in a repeat. load on seats makes entering the step wait. validate refuses to leave payment and guards.enter refuses to enter review - two different rules that are often mistaken for one switch, and the run below refuses on each of them at a moment the other is not even consulted.

The definition names both functions rather than holding them, which is what keeps it JSON:

registry.ts
import type { Scope } from '@wizzard-packages/core';
/** Where the seat map lands: a store, a cache - the engine does not care. */
export const seatsByRow: number[] = [];
/**
* The two kinds of named function a definition can hold, side by side.
*
* `seatMap` is a `load`: the engine awaits it as the step is entered and
* discards what it returns, so the resolver puts the answer somewhere itself.
* `cardAccepted` is a `validate`: it answers with a map of messages, or `null`
* when the step may be left.
*/
export const registry = {
// A timer rather than a resolved promise, so the answer arrives a whole task
// later - long after anything that forgot to wait would have moved on.
seatMap: async (): Promise<void> => {
await new Promise((settle) => setTimeout(settle, 10));
seatsByRow.splice(0, seatsByRow.length, 11, 12, 14);
},
cardAccepted: (_args: unknown, scope: Scope) => {
const payment = scope.data.payment as { card?: string } | undefined;
const card = payment?.card ?? '';
return card.length >= 4 ? null : { card: 'Enter the last four digits' };
},
};

Driven by hand, so every one of those claims is a line of output rather than a promise. A binding makes these same calls for you.

headless.ts
import { createWizard } from '@wizzard-packages/core';
import type { NavResult, Wizard, WizardState } from '@wizzard-packages/core';
import { groups } from '@wizzard-packages/core/groups';
import { decodeSnapshot, toSnapshot } from '@wizzard-packages/core/snapshot';
import { booking, passenger } from './flow';
import { registry, seatsByRow } from './registry';
/**
* The whole flow walked by hand, so every feature it holds is visible as a
* line of output rather than as a claim in prose.
*
* A binding makes these same calls for you; nothing here is a test helper.
*/
const open = (state?: WizardState): Wizard<typeof booking> =>
createWizard({
flow: booking,
groups,
subFlows: { passenger },
registry,
...(state === undefined ? {} : { state }),
});
let wizard: Wizard<typeof booking> = open();
/** The stack, not the step: inside a group, where you are needs every frame. */
const where = (): string =>
wizard
.getSnapshot()
.stack.map((f) => (f.key === undefined ? f.step : `${f.step}[${f.key}]`))
.join(' / ');
/** What refused a move, by the rule's own name for itself. */
const why = (r: NavResult): string => {
if (r.ok) return 'nothing';
return `${r.reason} by ${String(r.by)}`;
};
await wizard.start();
console.log(`at: ${where()}`);
wizard.set('trip', {
destination: 'JP',
travellers: [
{ id: 'a1', name: 'Ada', type: 'adult' },
{ id: 'c1', name: 'Cy', type: 'child' },
],
});
// The policy is `free`, so jumping is allowed and nothing has been left, so
// nothing is validated. The guard on `review` refuses anyway: it is a rule
// about the step being entered, not about the move that reached it.
const early = await wizard.go('review');
console.log(`refused: ${why(early)}`);
// Into the group: one pass of the sub-flow per traveller, keyed by id.
await wizard.next();
console.log(`at: ${where()}`);
// A step inside a repeat runs once per item, so what it collects is written at
// a path carrying the item's key.
wizard.set('people.a1.passport', 'PA118');
await wizard.next();
console.log(`at: ${where()}`);
// The child skips `meal`, because the branch is inside the repeat.
await wizard.next();
console.log(`at: ${where()}`);
// Written out and read back while the wizard stands two frames deep, which is
// the case a flat snapshot cannot express: the group, its key and the step
// inside it all have to survive.
const written = JSON.stringify(toSnapshot(wizard.getState(), booking));
const saved: unknown = JSON.parse(written);
const restored = decodeSnapshot(booking, saved, { subFlows: { passenger } });
if (!restored.restored) throw new Error(restored.reason);
wizard = open(restored.state);
console.log(`restored at: ${where()}`);
console.log(`restored answer: ${String(wizard.get('people.a1.passport'))}`);
// Out of the group and into the step that loads before it is shown. Every
// commit is watched: if `seats` were ever current while its map was still
// missing, the engine would have shown the step before `load` finished.
let shownEarly = false;
const stop = wizard.subscribe(() => {
const current = wizard.getSnapshot().current;
if (current === 'seats' && seatsByRow.length === 0) shownEarly = true;
});
await wizard.next();
stop();
console.log(`at: ${where()}`);
console.log(`shown before its map loaded: ${shownEarly ? 'yes' : 'no'}`);
// The branch, both ways. `visa` is part of the flow only while the answer
// says Japan, and the steps ahead are computed from it on every read.
wizard.set('trip.destination', 'FR');
console.log(`ahead for FR: ${wizard.getSnapshot().active.join(', ')}`);
wizard.set('trip.destination', 'JP');
console.log(`ahead for JP: ${wizard.getSnapshot().active.join(', ')}`);
await wizard.next();
console.log(`at: ${where()}`);
await wizard.next();
console.log(`at: ${where()}`);
// The other refusal, from the other rule: the validator on the step being left.
const refused = await wizard.next();
console.log(`refused: ${why(refused)}`);
wizard.set('payment', { card: '4242' });
await wizard.next();
console.log(`at: ${where()}`);
Output
at: trip
refused: blocked by review
at: people[a1] / who
at: people[a1] / meal
at: people[c1] / who
restored at: people[c1] / who
restored answer: PA118
at: seats
shown before its map loaded: no
ahead for FR: trip, people, seats, payment, review
ahead for JP: trip, people, seats, visa, payment, review
at: visa
at: payment
refused: invalid by payment
at: review

A test in the repository asserts those exact lines, so it is not a transcript of a good run.

Line What it is
refused: blocked by review the guard refused a jump the policy allowed, before any step had been left and any validator had run - guards.enter is about the step being entered
at: people[a1] / who position is a stack, not a step: the group is one frame and the sub-flow’s step is the next
at: people[a1] / meal then at: people[c1] / who the adult gets the meal step and the child does not - a branch inside the repeat
restored at: people[c1] / who the snapshot was written two frames deep, and the restore came back to the same place - the group, the traveller’s key and the step inside it
restored answer: PA118 the answers keyed by traveller came back with it, into an engine that had never seen them
at: seats the last traveller’s pass ended and so did the group, both decided by the traversal rather than by the caller
shown before its map loaded: no every commit of that move was watched, and seats was never current while its map was missing - the move waited for load, which answers a whole timer later
ahead for FR and ahead for JP the branch both ways: visa is in the steps ahead only while the answer says Japan, and they are recomputed on every read rather than stored
at: visa the branch taken, long after the answer that decided it
refused: invalid by payment the other rule, on the step being left: the validator refused and the flow stayed put - a result, never a thrown error
at: review the card satisfied both of them, so the move that had been refused twice went through

Two things this example leaves out on purpose, because both are about where the definition comes from rather than what it can say.

A flow can change shape while it runs: patchFlow replaces steps on a live wizard, which is how a backend drives the wizard step by step. Server-driven flows is that page.

A flow can be persisted automatically rather than by hand. The run above calls toSnapshot and decodeSnapshot itself to keep the script honest about what is happening; Persistence is the plugin that does it on every commit, and the refusals that matter when the flow has changed since.

Groups, repeat and sub-flows covers people, its repeat and its input. Expressions is the language when and guards are written in. Loading a step covers load and the step that arrives late. Validation separates checking the definition from checking the answers. Navigation covers the moves and the refusals they return. Concepts and glossary defines every word used here.