Tutorial
Getting started is one screen, and What you can build is a finished flow you read rather than build. This page is the road between them: a conference registration that starts as two steps and grows, one change at a time, into a wizard with validation, a branch, a list of guests, a step that loads, a session that survives a reload and a plugin you wrote.
Every step changes a file or two and adds a short script that drives the wizard and prints what the change made work. A file that already existed is shown as the change to it, so what you type is the difference. The code and the output on this page are read from files in the repository, and a test runs every script and compares what it prints, so a line here cannot say something the engine does not do.
The scripts need @wizzard-packages/core - install it as Getting started
shows - and a way to run TypeScript, such as npx tsx run.ts. Step 6 adds the plugins package.
The browser comes in at the end.
1. Two steps and a review
Section titled “1. Two steps and a review”A flow is data: the order of the steps and what each one is. step<T> names the shape of the
answers a step collects, which is what lets set and get check them; the object it returns is
still plain JSON.
import { defineFlow, step } from '@wizzard-packages/core';
/** * A conference registration, and the whole of it so far: who is coming, which * ticket, and a page to check both before sending. * * `step<T>` declares the shape of the answers a step collects, so `set` and * `get` know it. The object itself is still plain data. */export const registration = defineFlow({ id: 'registration', order: ['attendee', 'ticket', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you' }), ticket: step<{ kind: 'standard' | 'business' }>({ label: 'Your ticket' }), review: step({ label: 'Review' }), },});The engine is built from the flow. It gets its own file because every later step adds something to this call.
import { createWizard } from '@wizzard-packages/core';
import { registration } from './flow';
/** * Where the engine is built. Every later step adds to this one call - a * registry, group traversal, plugins - so it lives in a file of its own. */export const openWizard = () => createWizard({ flow: registration });import { openWizard } from './wizard';
const wizard = openWizard();
// Nothing is current until `start` enters the first step.await wizard.start();console.log(`at: ${String(wizard.getSnapshot().current)}`);
wizard.set('attendee', { name: 'Ada', email: 'ada@example.com' });await wizard.next();console.log(`at: ${String(wizard.getSnapshot().current)}`);
// Back does not throw away what was typed on the step it returns to.await wizard.back();console.log(`at: ${String(wizard.getSnapshot().current)}`);console.log(`name: ${String(wizard.get('attendee')?.name)}`);console.log(`steps: ${wizard.getSnapshot().active.join(', ')}`);at: attendeeat: ticketat: attendeename: Adasteps: attendee, ticket, reviewNothing is current until start() enters the first step. back() moves, and it does not throw
away what was typed on the step it returns to. active is the route from start to end, worked
out from the flow rather than stored.
2. Refuse to leave a step
Section titled “2. Refuse to leave a step”A step that must be complete before the person moves on gets a validate. The flow cannot hold
a function and stay JSON, so it holds a name, and a registry says what the name means.
// ... id: 'registration', order: ['attendee', 'ticket', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you' }), attendee: step<{ name: string; email: string }>({ label: 'About you', // A name, not a function: the flow stays JSON, and `registry.ts` says // what the name means. validate: { $ref: 'attendeeComplete' }, }), ticket: step<{ kind: 'standard' | 'business' }>({ label: 'Your ticket' }), review: step({ label: 'Review' }), },// ...import type { AsyncRegistry } from '@wizzard-packages/core';
/** * The functions the flow names. A validator reads the answers and returns a * message per field that is wrong, or `null` when the step may be left. */export const registry: AsyncRegistry = { attendeeComplete: (_args, scope) => { const attendee = scope.data['attendee'] as { name?: string; email?: string } | undefined; const errors: Record<string, string> = {}; if (!attendee?.name?.trim()) errors['name'] = 'Enter your name.'; if (!attendee?.email?.includes('@')) errors['email'] = 'Enter an email address.'; return Object.keys(errors).length === 0 ? null : errors; },};import { createWizard } from '@wizzard-packages/core';
import { registration } from './flow';import { registry } from './registry';
/** * Where the engine is built. Every later step adds to this one call - a * registry, group traversal, plugins - so it lives in a file of its own. */export const openWizard = () => createWizard({ flow: registration });export const openWizard = () => createWizard({ flow: registration, registry });import { openWizard } from './wizard';
const wizard = openWizard();await wizard.start();
wizard.set('attendee', { name: 'Ada', email: '' });
// A refusal is a value, not an exception: read it, do not catch it.const refused = await wizard.next();if (!refused.ok) console.log(`refused: ${refused.reason} ${JSON.stringify(refused.errors)}`);console.log(`at: ${String(wizard.getSnapshot().current)}`);
// The messages are also in the wizard's state, where a form reads them.console.log(`errors on attendee: ${JSON.stringify(wizard.getState().errors['attendee'])}`);
wizard.set('attendee.email', 'ada@example.com');const moved = await wizard.next();console.log(`next: ${moved.ok ? 'ok' : moved.reason}`);console.log(`at: ${String(wizard.getSnapshot().current)}`);refused: invalid {"email":"Enter an email address."}at: attendeeerrors on attendee: {"email":"Enter an email address."}next: okat: ticketA refused next() is a value with a reason and the messages, not an exception, and the wizard
stays where it was. The same messages sit in the wizard’s state under the step’s id, which is
where a form reads them: Block Next until valid and
Render field errors are the rendered side of this step.
3. A step for some tickets only
Section titled “3. A step for some tickets only”A business ticket needs a company name; a standard one does not. when puts a step on the route
only while an expression holds, and the expression is data too - eq(get(...), ...) builds it.
import { defineFlow, step } from '@wizzard-packages/core';import { eq, get } from '@wizzard-packages/core/expr';
/** * A conference registration, and the whole of it so far: who is coming, which// ... */export const registration = defineFlow({ id: 'registration', order: ['attendee', 'ticket', 'review'], order: ['attendee', 'ticket', 'company', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you',// ... validate: { $ref: 'attendeeComplete' }, }), ticket: step<{ kind: 'standard' | 'business' }>({ label: 'Your ticket' }), // On the route only while the ticket says business. `eq(get(...), ...)` // builds an expression - data, like the rest of the flow. company: step<{ name: string }>({ label: 'Your company', when: eq(get('data.ticket.kind'), 'business'), }), review: step({ label: 'Review' }), },});import { openWizard } from './wizard';
const wizard = openWizard();await wizard.start();wizard.set('attendee', { name: 'Ada', email: 'ada@example.com' });await wizard.next();
// The steps ahead are worked out from the answers on every read.wizard.set('ticket', { kind: 'standard' });console.log(`standard: ${wizard.getSnapshot().active.join(', ')}`);await wizard.next();console.log(`at: ${String(wizard.getSnapshot().current)}`);
await wizard.back();wizard.set('ticket', { kind: 'business' });console.log(`business: ${wizard.getSnapshot().active.join(', ')}`);await wizard.next();console.log(`at: ${String(wizard.getSnapshot().current)}`);standard: attendee, ticket, reviewat: reviewbusiness: attendee, ticket, company, reviewat: companyThe route is worked out from the answers on every read, so changing the ticket changes the steps
ahead without anything being told to recompute. With a standard ticket next() from ticket
lands on review; with a business one it lands on company. What the person typed on a step
that then goes off the route stays in the data;
Clear abandoned branch data is how to decide what does not.
4. The same questions for every guest
Section titled “4. The same questions for every guest”A ticket can bring guests, and each guest is asked the same two things. That is a flow of its
own, guest, run once per item of a list by a group with a repeat.
import { defineFlow, step } from '@wizzard-packages/core';import { eq, get } from '@wizzard-packages/core/expr';import { defineFlow, group, step } from '@wizzard-packages/core';import { empty, eq, get, not } from '@wizzard-packages/core/expr';
/** Someone the attendee brings along. `id` is what keeps their answers theirs. */export interface Guest { id: string; name: string;}
/** * What each guest is asked. An ordinary flow: it does not know it runs once * per guest. */export const guest = defineFlow({ id: 'guest', order: ['badge', 'diet'], steps: { badge: step({ label: 'Name on the badge' }), diet: step({ label: 'Dietary needs' }), },});
/** * A conference registration, and the whole of it so far: who is coming, which * ticket, and a page to check both before sending. *// ... */export const registration = defineFlow({ id: 'registration', order: ['attendee', 'ticket', 'company', 'review'], // A flow with a repeat carries a version: a saved session names guests of a // list that may have changed shape since. version: 1, order: ['attendee', 'ticket', 'company', 'guests', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you',// ... // what the name means. validate: { $ref: 'attendeeComplete' }, }), ticket: step<{ kind: 'standard' | 'business' }>({ label: 'Your ticket' }), ticket: step<{ kind: 'standard' | 'business'; guests: Guest[] }>({ label: 'Your ticket' }), // On the route only while the ticket says business. `eq(get(...), ...)` // builds an expression - data, like the rest of the flow. company: step<{ name: string }>({ label: 'Your company', when: eq(get('data.ticket.kind'), 'business'), }), // The `guest` flow, once per guest, keyed by id - and skipped entirely // while there are none, which a repeat is required to say. guests: group({ label: 'Guests', flow: 'guest', when: not(empty(get('data.ticket.guests'))), repeat: { over: get('data.ticket.guests'), keyBy: 'id' }, }), review: step({ label: 'Review' }), },});Three fields beside the group are not optional. keyBy names what tells one guest from another,
so a guest keeps their answers when the list is edited. The group’s when has to say what
happens while the list is empty (repeat-without-when), and
a flow with a repeat carries a version
(repeat-without-version), because a saved session
names guests of a list that may have changed shape since.
Walking into a group is a separate entry point, so a flow without one does not pay for it:
import { createWizard } from '@wizzard-packages/core';import { groups } from '@wizzard-packages/core/groups';
import { registration } from './flow';import { guest, registration } from './flow';import { registry } from './registry';
/** * Where the engine is built. Every later step adds to this one call - a * registry, group traversal, plugins - so it lives in a file of its own. * * Walking a group is a separate entry point, so a flow without one does not * carry it; `subFlows` answers the name the group's `flow` gives. */export const openWizard = () => createWizard({ flow: registration, registry });export const openWizard = () => createWizard({ flow: registration, registry, groups, subFlows: { guest } });import { openWizard } from './wizard';
const wizard = openWizard();
/** Where the wizard is: the group, the guest it is on, and the step inside. */const where = (): string => wizard .getSnapshot() .stack.map((f) => (f.key === undefined ? f.step : `${f.step}[${f.key}]`)) .join(' / ');
await wizard.start();wizard.set('attendee', { name: 'Ada', email: 'ada@example.com' });await wizard.next();
wizard.set('ticket', { kind: 'standard', guests: [] });console.log(`no guests: ${wizard.getSnapshot().active.join(', ')}`);
wizard.set('ticket.guests', [ { id: 'g1', name: 'Grace' }, { id: 'g2', name: 'Linus' },]);console.log(`two guests: ${wizard.getSnapshot().active.join(', ')}`);
await wizard.next();console.log(`at: ${where()}`);
// The engine does not decide where a guest's answers go; a path with the// guest's key in it keeps them apart.wizard.set('guests.g1.diet', 'vegetarian');await wizard.next();console.log(`at: ${where()}`);await wizard.next();console.log(`at: ${where()}`);await wizard.next();await wizard.next();console.log(`at: ${where()}`);console.log(`Grace eats: ${String(wizard.get('guests.g1.diet'))}`);no guests: attendee, ticket, reviewtwo guests: attendee, ticket, guests, reviewat: guests[g1] / badgeat: guests[g1] / dietat: guests[g2] / badgeat: reviewGrace eats: vegetarianWhere the wizard is, inside a group, is a stack: the group with the guest’s key, then the step of
the guest’s flow. The engine does not decide where a guest’s answers are kept; a path with the
guest’s key in it, guests.g1.diet, keeps them apart. Groups, repeat and
sub-flows covers the rest - what a nested flow can read about its item, and
back() across a group’s edge.
5. A step that waits for its data
Section titled “5. A step that waits for its data”The sessions a person can pick come from a server. load names a resolver the move waits on, so
no move makes the step current with nothing to pick from.
// ... // A flow with a repeat carries a version: a saved session names guests of a // list that may have changed shape since. version: 1, order: ['attendee', 'ticket', 'company', 'guests', 'review'], order: ['attendee', 'ticket', 'company', 'guests', 'sessions', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you',// ... when: not(empty(get('data.ticket.guests'))), repeat: { over: get('data.ticket.guests'), keyBy: 'id' }, }), // Entered only once the agenda is in: the move waits on `load`, and the // step is never current with nothing to pick from. sessions: step<{ picks: string[] }>({ label: 'Sessions', load: { $ref: 'agenda' }, }), review: step({ label: 'Review' }), },});load gates the step and discards what the resolver returns, so the resolver puts the answer
where the page will read it - here a module, in an app a store or a query cache.
import type { AsyncRegistry } from '@wizzard-packages/core';
/** * Where the agenda lands. `load` gates the step and discards what it returns, * so the resolver puts the answer somewhere itself - here, a module; in an app, * a store or a query cache. */export const talks: string[] = [];
/** * The functions the flow names. A validator reads the answers and returns a * message per field that is wrong, or `null` when the step may be left. */// ... if (!attendee?.email?.includes('@')) errors['email'] = 'Enter an email address.'; return Object.keys(errors).length === 0 ? null : errors; },
// Stands in for a fetch. A timer rather than a resolved promise, so the // answer arrives a whole task later - long after anything that did not wait // would have moved on. agenda: async () => { await new Promise((settle) => setTimeout(settle, 10)); talks.splice(0, talks.length, 'Keynote', 'Flows as data', 'Testing wizards'); },};import { talks } from './registry';import { openWizard } from './wizard';
const wizard = openWizard();await wizard.start();wizard.set('attendee', { name: 'Ada', email: 'ada@example.com' });await wizard.next();wizard.set('ticket', { kind: 'standard', guests: [] });
// Every commit of the move is watched: the wizard reports itself busy while// the agenda loads, and `sessions` never becomes current before it arrives.let waited = false;let shownEmpty = false;const stop = wizard.subscribe(() => { const now = wizard.getSnapshot(); if (now.isBusy) waited = true; if (now.current === 'sessions' && talks.length === 0) shownEmpty = true;});await wizard.next();stop();
console.log(`busy while loading: ${waited ? 'yes' : 'no'}`);console.log(`shown before the agenda arrived: ${shownEmpty ? 'yes' : 'no'}`);console.log(`at: ${String(wizard.getSnapshot().current)}`);console.log(`talks: ${talks.join(', ')}`);busy while loading: yesshown before the agenda arrived: noat: sessionstalks: Keynote, Flows as data, Testing wizardsWhile the agenda loads the wizard reports itself busy, which is what the buttons of the rendered
wizard below are disabled on, and no commit ever shows sessions current before the talks are in.
Loading a step covers a load that fails and one that is overtaken by another
move.
6. Keep the session across a reload
Section titled “6. Keep the session across a reload”The persist plugin writes the session on every change and reads it back when the wizard is created.
It ships in its own package, beside the engine:
pnpm add @wizzard-packages/core@canary @wizzard-packages/plugins@canaryIn a browser it uses localStorage; the script hands it a Map behind the same two methods, so
a reload can happen in one process.
import { createWizard } from '@wizzard-packages/core';import { groups } from '@wizzard-packages/core/groups';import { persist, type RestoreOutcome, type SyncStorage } from '@wizzard-packages/plugins/persist';
import { guest, registration } from './flow';import { registry } from './registry';
export interface OpenOptions { /** `localStorage` when left out. */ storage?: SyncStorage; /** Told once, at start, whether a saved session came back and why not. */ onRestore?: (outcome: RestoreOutcome) => void;}
/** * Where the engine is built. Every later step adds to this one call - a * registry, group traversal, plugins - so it lives in a file of its own.// ... * Walking a group is a separate entry point, so a flow without one does not * carry it; `subFlows` answers the name the group's `flow` gives. */export const openWizard = () => createWizard({ flow: registration, registry, groups, subFlows: { guest } });export const openWizard = ({ storage, onRestore }: OpenOptions = {}) => createWizard({ flow: registration, registry, groups, subFlows: { guest }, // Writes the session on every change and reads it back at start. `version` // is the app's own: bump it when stored answers stop meaning what they did. plugins: [ persist({ key: 'registration', version: 1, ...(storage === undefined ? {} : { storage }), ...(onRestore === undefined ? {} : { onRestore }), }), ], });import type { RestoreOutcome, SyncStorage } from '@wizzard-packages/plugins/persist';
import { openWizard } from './wizard';
// A reload without a browser: the plugin needs two methods of storage, and// a Map behind them is enough to show the round trip.const kept = new Map<string, string>();const storage: SyncStorage = { getItem: (key) => kept.get(key) ?? null, setItem: (key, value) => { kept.set(key, value); },};
const said = (outcome: RestoreOutcome | null): string => outcome === null ? 'nothing' : outcome.restored ? 'restored' : outcome.reason;
let outcome: RestoreOutcome | null = null;const first = openWizard({ storage, onRestore: (o) => (outcome = o) });await first.start();console.log(`first visit: ${said(outcome)}`);
first.set('attendee', { name: 'Ada', email: 'ada@example.com' });await first.next();console.log(`left on: ${String(first.getSnapshot().current)}`);
// The tab closes. Writes are coalesced, and `destroy` flushes the last one -// in a browser, the plugin does the same on `pagehide`.first.destroy();
outcome = null;const second = openWizard({ storage, onRestore: (o) => (outcome = o) });await second.start();console.log(`after reload: ${said(outcome)}`);console.log(`back on: ${String(second.getSnapshot().current)}`);console.log(`name kept: ${String(second.get('attendee')?.name)}`);first visit: persist/nothing-storedleft on: ticketafter reload: restoredback on: ticketname kept: AdaonRestore is told once, when the wizard is created, whether a session came back and, when one did not, why - here
persist/nothing-stored on the first visit. Writes are coalesced, and destroy() flushes the
last one, as closing the page does in a browser. Restore after reload
is this step rendered, and Persistence is the format underneath.
A restore puts the wizard back on its step before start(), and start() enters a step that
loads again, in place: a page reloaded on sessions fetches the agenda step 5 loads before the
step reads as ready, and back() still goes where it went before the reload.
7. A plugin of your own
Section titled “7. A plugin of your own”persist is a plugin, and nothing about it is privileged: a plugin is an object with a name and
the hooks it needs. This one reports every step a move lands on, which is the shape of an
analytics event.
import type { Hooks } from '@wizzard-packages/core';
/** * A plugin of your own: an object with a name and the hooks it needs. This one * reports every step a move lands on - the shape of an analytics event. * * `afterNavigate` runs once a move has committed, so it observes and cannot * undo; a plugin that must stop a move returns a refusal from * `beforeNavigate` instead. */export const trail = (report: (step: string) => void): Hooks => ({ name: 'trail', afterNavigate: ({ to }) => { report(String(to)); },});// ...
import { guest, registration } from './flow';import { registry } from './registry';import { trail } from './trail';
export interface OpenOptions { /** `localStorage` when left out. */ storage?: SyncStorage; /** Told once, at start, whether a saved session came back and why not. */ onRestore?: (outcome: RestoreOutcome) => void; /** Told every step a move lands on. */ onStep?: (step: string) => void;}
/**// ... * Walking a group is a separate entry point, so a flow without one does not * carry it; `subFlows` answers the name the group's `flow` gives. */export const openWizard = ({ storage, onRestore }: OpenOptions = {}) =>export const openWizard = ({ storage, onRestore, onStep }: OpenOptions = {}) => createWizard({ flow: registration, registry,// ... ...(storage === undefined ? {} : { storage }), ...(onRestore === undefined ? {} : { onRestore }), }), ...(onStep === undefined ? [] : [trail(onStep)]), ], });import type { SyncStorage } from '@wizzard-packages/plugins/persist';
import { openWizard } from './wizard';
const kept = new Map<string, string>();const storage: SyncStorage = { getItem: (key) => kept.get(key) ?? null, setItem: (key, value) => { kept.set(key, value); },};
const landed: string[] = [];const wizard = openWizard({ storage, onStep: (step) => landed.push(step) });
await wizard.start();wizard.set('attendee', { name: 'Ada', email: '' });// Refused by the validator: nothing committed, so nothing to report.await wizard.next();wizard.set('attendee.email', 'ada@example.com');await wizard.next();wizard.set('ticket', { kind: 'standard', guests: [{ id: 'g1', name: 'Grace' }] });await wizard.next();await wizard.next();await wizard.next();
console.log(`landed on: ${landed.join(' -> ')}`);landed on: attendee -> ticket -> badge -> diet -> sessionsThe refused next() is not in the trail: afterNavigate runs once a move has committed, so it
sees only moves that happened, and it cannot undo one. A session persist restores is not a
move either: the wizard below, reloaded, does not list the step it came back on - unless that
is sessions, which it enters again to fetch the agenda. A plugin that must stop a move refuses it from beforeNavigate.
Writing a plugin lists every hook and the order they run in.
The wizard, rendered
Section titled “The wizard, rendered”Everything above, drawn by a binding. App hands the provider the options openWizard passed to
createWizard, and the components ask which step is current and draw that one. The session is
kept in this browser, one per tab, so leave it half done and reload the page.
The provider, and one component that draws whichever step is current.
import { groups } from '@wizzard-packages/core/groups';import { persist } from '@wizzard-packages/plugins/persist';import { WizardProvider, useErrors, useField, useNavigation, useStep, useWizard, useWizardSelector,} from '@wizzard-packages/react';import { useId, useMemo, useState, type ReactNode } from 'react';
import { guest, registration, type Guest } from '../07-plugin/flow';import { registry, talks } from '../07-plugin/registry';import { trail } from '../07-plugin/trail';import { nextGuestId, storageKey, withoutGuest } from './guests';
/** * The tutorial's wizard, rendered. The provider takes the options `openWizard` * passed to `createWizard` and builds the engine itself; everything below it * asks which step is current and draws that one. */export function App(): ReactNode { const [landed, setLanded] = useState<string[]>([]);
// Built once, and in the browser only: a server has no storage to restore // from. `trail` reports after a move commits, never during a render. const plugins = useMemo( () => typeof window === 'undefined' ? [] : [ persist({ key: storageKey('react'), version: 1 }), trail((step) => setLanded((steps) => [...steps, step])), ], [] );
return ( <WizardProvider flow={registration} registry={registry} groups={groups} subFlows={{ guest }} plugins={plugins} > <Registration /> <p>Landed on: {landed.length === 0 ? 'nothing yet' : landed.join(' → ')}</p> </WizardProvider> );}
export function Registration(): ReactNode { const wizard = useWizard(); const { current, status } = useStep(); const { next, back, canBack, isBusy, isLast } = useNavigation(); const errors = useErrors(); const id = useId();
// Inside the group the stack carries the guest's key; outside it, nothing does. const key = useWizardSelector((s) => s.stack.find((f) => f.key !== undefined)?.key ?? null);
const [name, setName] = useField<string | undefined>('attendee.name'); const [email, setEmail] = useField<string | undefined>('attendee.email'); const [kind, setKind] = useField<string | undefined>('ticket.kind'); const [list, setList] = useField<Guest[] | undefined>('ticket.guests'); const [company, setCompany] = useField<string | undefined>('company.name'); const [badge, setBadge] = useField<string | undefined>(`guests.${key ?? '-'}.badge`); const [diet, setDiet] = useField<string | undefined>(`guests.${key ?? '-'}.diet`); const [picks, setPicks] = useField<string[] | undefined>('sessions.picks');
const guests = list ?? []; const who = guests.find((g) => g.id === key)?.name ?? '';
if (status === 'done') { return ( <section> <h3>You are registered</h3> <button type="button" onClick={() => { wizard.reset(); void wizard.start(); }} > Start over </button> </section> ); }
// A generated id: the React and Vue renderings share the page, so a written // one would be in the document twice. const field = ( slug: string, label: string, value: string | undefined, set: (v: string) => void, err?: string ): ReactNode => ( <p> <label htmlFor={`${id}-${slug}`}>{label}</label> <input id={`${id}-${slug}`} value={value ?? ''} onChange={(e) => set(e.target.value)} aria-invalid={err !== undefined} /> {err !== undefined && <span role="alert">{err}</span>} </p> );
return ( <form onSubmit={(e) => { e.preventDefault(); void next(); }} > {current === 'attendee' && ( <> {field('name', 'Name', name, setName, errors['name'])} {field('email', 'Email', email, setEmail, errors['email'])} </> )}
{current === 'ticket' && ( <> <fieldset> <legend>Ticket</legend> {(['standard', 'business'] as const).map((k) => ( <label key={k}> <input type="radio" name={`${id}-kind`} checked={kind === k} onChange={() => setKind(k)} />{' '} {k} </label> ))} </fieldset> {guests.map((g, at) => ( <p key={g.id}> <input aria-label={`Guest ${at + 1}`} value={g.name} onChange={(e) => setList(guests.map((x) => (x.id === g.id ? { ...x, name: e.target.value } : x))) } /> <button type="button" onClick={() => withoutGuest(wizard, g.id)}> Remove </button> </p> ))} <button type="button" onClick={() => setList([...guests, { id: nextGuestId(guests), name: '' }])} > Add a guest </button> </> )}
{current === 'company' && field('company', 'Company', company, setCompany)}
{current === 'badge' && field('badge', `Name on ${who || 'the guest'}'s badge`, badge, setBadge)}
{current === 'diet' && ( <p> <label htmlFor={`${id}-diet`}>What does {who || 'the guest'} eat?</label> <select id={`${id}-diet`} value={diet ?? ''} onChange={(e) => setDiet(e.target.value)}> <option value="">Anything</option> <option value="vegetarian">Vegetarian</option> <option value="vegan">Vegan</option> </select> </p> )}
{current === 'sessions' && ( <fieldset> <legend>Sessions</legend> {talks.map((talk) => ( <label key={talk}> <input type="checkbox" checked={(picks ?? []).includes(talk)} onChange={(e) => setPicks( e.target.checked ? [...(picks ?? []), talk] : (picks ?? []).filter((p) => p !== talk) ) } />{' '} {talk} </label> ))} </fieldset> )}
{current === 'review' && ( <p> {name} ({email}), {kind ?? 'standard'} ticket {guests.length > 0 ? `, with ${guests.map((g) => g.name).join(', ')}` : ''}. </p> )}
<button type="button" onClick={() => void back()} disabled={!canBack || isBusy}> Back </button> <button type="submit" disabled={current === null || isBusy}> {isLast ? 'Register' : 'Next'} </button> </form> );}Removing a guest takes their answers with them, in one commit.
import type { Wizard } from '@wizzard-packages/core';
import type { Guest } from '../07-plugin/flow';
/** * Where the rendered tutorial keeps its session, one key per binding. The page * mounts the React and the Vue wizard at once; under one key the one left idle * in its tab would write its older session over the other's progress. */export const storageKey = (binding: 'react' | 'vue'): string => `wizzard-tutorial-registration-${binding}`;
/** * A key no guest has, and no guest's answers still sit under. The key is what * the group's frames and the answer paths name, so it is never reused. */export const nextGuestId = (guests: readonly Guest[]): string => { let n = guests.length + 1; while (guests.some((g) => g.id === `g${n}`)) n += 1; return `g${n}`;};
/** * A guest leaves with their answers, in one commit. Their key is part of a data * path, and answers left behind under it would belong to whoever is given that * key next. */export const withoutGuest = (wizard: Wizard, id: string): void => { const data = wizard.getState().data; const guests = ((data['ticket'] as { guests?: Guest[] } | undefined)?.guests ?? []).filter( (g) => g.id !== id ); const answers = { ...((data['guests'] as Record<string, unknown> | undefined) ?? {}) }; delete answers[id]; wizard.batch(() => { wizard.set('ticket.guests', guests); wizard.set('guests', answers); });};<script setup lang="ts">import { groups } from '@wizzard-packages/core/groups';import { persist } from '@wizzard-packages/plugins/persist';import { provideWizard } from '@wizzard-packages/vue';import { ref } from 'vue';
import Wizard from './Wizard.vue';import { guest, registration } from '../07-plugin/flow';import { registry } from '../07-plugin/registry';import { trail } from '../07-plugin/trail';import { storageKey } from './guests';
/** * The tutorial's wizard, rendered. `provideWizard` takes the options * `openWizard` passed to `createWizard` and builds the engine itself. It has to * run in a parent: `inject` reads the parent chain, so the component that * provides cannot also use the composables. */const landed = ref<string[]>([]);
// In the browser only: a server has no storage to restore from.const plugins = typeof window === 'undefined' ? [] : [ persist({ key: storageKey('vue'), version: 1 }), trail((step) => { landed.value = [...landed.value, step]; }), ];
provideWizard({ flow: registration, registry, groups, subFlows: { guest }, plugins });</script>
<template> <Wizard /> <p>Landed on: {{ landed.length === 0 ? 'nothing yet' : landed.join(' → ') }}</p></template><script setup lang="ts">import { useErrors, useField, useNavigation, useStep, useWizard, useWizardSelector,} from '@wizzard-packages/vue';import { computed, useId } from 'vue';
import type { Guest } from '../07-plugin/flow';import { talks } from '../07-plugin/registry';import { nextGuestId, withoutGuest } from './guests';
const wizard = useWizard();const { current, status } = useStep();const { next, back, canBack, isBusy, isLast } = useNavigation();const errors = useErrors();// One per mounted form: the React and Vue renderings share the page.const id = useId();
// Inside the group the stack carries the guest's key; outside it, nothing does.const key = useWizardSelector((s) => s.stack.find((f) => f.key !== undefined)?.key ?? null);
const name = useField<string | undefined>('attendee.name');const email = useField<string | undefined>('attendee.email');const kind = useField<string | undefined>('ticket.kind');const list = useField<Guest[] | undefined>('ticket.guests');const company = useField<string | undefined>('company.name');const picks = useField<string[] | undefined>('sessions.picks');
// `useField` takes a fixed path, and these two move with the guest.const data = useWizardSelector((s) => s.data);const answer = (field: 'badge' | 'diet') => computed({ get: () => ((data.value['guests'] as Record<string, Record<string, string>> | undefined) ?? {})[ key.value ?? '' ]?.[field] ?? '', set: (value: string) => { if (key.value !== null) wizard.set(`guests.${key.value}.${field}`, value); }, });const badge = answer('badge');const diet = answer('diet');
const guests = computed(() => list.value ?? []);const who = computed(() => guests.value.find((g) => g.id === key.value)?.name || 'the guest');
const rename = (guestId: string, value: string): void => { list.value = guests.value.map((g) => (g.id === guestId ? { ...g, name: value } : g));};const toggle = (talk: string, on: boolean): void => { const now = picks.value ?? []; picks.value = on ? [...now, talk] : now.filter((p) => p !== talk);};const startOver = (): void => { wizard.reset(); void wizard.start();};</script>
<template> <section v-if="status === 'done'"> <h3>You are registered</h3> <button type="button" @click="startOver">Start over</button> </section>
<form v-else @submit.prevent="next()"> <template v-if="current === 'attendee'"> <p> <label :for="`${id}-name`">Name</label> <input :id="`${id}-name`" v-model="name" :aria-invalid="errors['name'] !== undefined" /> <span v-if="errors['name'] !== undefined" role="alert">{{ errors['name'] }}</span> </p> <p> <label :for="`${id}-email`">Email</label> <input :id="`${id}-email`" v-model="email" :aria-invalid="errors['email'] !== undefined" /> <span v-if="errors['email'] !== undefined" role="alert">{{ errors['email'] }}</span> </p> </template>
<template v-else-if="current === 'ticket'"> <fieldset> <legend>Ticket</legend> <label v-for="k in ['standard', 'business']" :key="k"> <input v-model="kind" type="radio" :name="`${id}-kind`" :value="k" /> {{ k }} </label> </fieldset> <p v-for="(g, at) in guests" :key="g.id"> <input :aria-label="`Guest ${at + 1}`" :value="g.name" @input="rename(g.id, ($event.target as HTMLInputElement).value)" /> <button type="button" @click="withoutGuest(wizard, g.id)">Remove</button> </p> <button type="button" @click="list = [...guests, { id: nextGuestId(guests), name: '' }]"> Add a guest </button> </template>
<p v-else-if="current === 'company'"> <label :for="`${id}-company`">Company</label> <input :id="`${id}-company`" v-model="company" /> </p>
<p v-else-if="current === 'badge'"> <label :for="`${id}-badge`">Name on {{ who }}'s badge</label> <input :id="`${id}-badge`" v-model="badge" /> </p>
<p v-else-if="current === 'diet'"> <label :for="`${id}-diet`">What does {{ who }} eat?</label> <select :id="`${id}-diet`" v-model="diet"> <option value="">Anything</option> <option value="vegetarian">Vegetarian</option> <option value="vegan">Vegan</option> </select> </p>
<fieldset v-else-if="current === 'sessions'"> <legend>Sessions</legend> <label v-for="talk in talks" :key="talk"> <input type="checkbox" :checked="(picks ?? []).includes(talk)" @change="toggle(talk, ($event.target as HTMLInputElement).checked)" /> {{ talk }} </label> </fieldset>
<p v-else-if="current === 'review'"> {{ name }} ({{ email }}), {{ kind ?? 'standard' }} ticket{{ guests.length > 0 ? `, with ${guests.map((g) => g.name).join(', ')}` : '' }}. </p>
<button type="button" :disabled="!canBack || isBusy" @click="back()">Back</button> <button type="submit" :disabled="current === null || isBusy"> {{ isLast ? 'Register' : 'Next' }} </button> </form></template>import type { Wizard } from '@wizzard-packages/core';
import type { Guest } from '../07-plugin/flow';
/** * Where the rendered tutorial keeps its session, one key per binding. The page * mounts the React and the Vue wizard at once; under one key the one left idle * in its tab would write its older session over the other's progress. */export const storageKey = (binding: 'react' | 'vue'): string => `wizzard-tutorial-registration-${binding}`;
/** * A key no guest has, and no guest's answers still sit under. The key is what * the group's frames and the answer paths name, so it is never reused. */export const nextGuestId = (guests: readonly Guest[]): string => { let n = guests.length + 1; while (guests.some((g) => g.id === `g${n}`)) n += 1; return `g${n}`;};
/** * A guest leaves with their answers, in one commit. Their key is part of a data * path, and answers left behind under it would belong to whoever is given that * key next. */export const withoutGuest = (wizard: Wizard, id: string): void => { const data = wizard.getState().data; const guests = ((data['ticket'] as { guests?: Guest[] } | undefined)?.guests ?? []).filter( (g) => g.id !== id ); const answers = { ...((data['guests'] as Record<string, unknown> | undefined) ?? {}) }; delete answers[id]; wizard.batch(() => { wizard.set('ticket.guests', guests); wizard.set('guests', answers); });};import { defineFlow, group, step } from '@wizzard-packages/core';import { empty, eq, get, not } from '@wizzard-packages/core/expr';
/** Someone the attendee brings along. `id` is what keeps their answers theirs. */export interface Guest { id: string; name: string;}
/** * What each guest is asked. An ordinary flow: it does not know it runs once * per guest. */export const guest = defineFlow({ id: 'guest', order: ['badge', 'diet'], steps: { badge: step({ label: 'Name on the badge' }), diet: step({ label: 'Dietary needs' }), },});
/** * A conference registration, and the whole of it so far: who is coming, which * ticket, and a page to check both before sending. * * `step<T>` declares the shape of the answers a step collects, so `set` and * `get` know it. The object itself is still plain data. */export const registration = defineFlow({ id: 'registration', // A flow with a repeat carries a version: a saved session names guests of a // list that may have changed shape since. version: 1, order: ['attendee', 'ticket', 'company', 'guests', 'sessions', 'review'], steps: { attendee: step<{ name: string; email: string }>({ label: 'About you', // A name, not a function: the flow stays JSON, and `registry.ts` says // what the name means. validate: { $ref: 'attendeeComplete' }, }), ticket: step<{ kind: 'standard' | 'business'; guests: Guest[] }>({ label: 'Your ticket' }), // On the route only while the ticket says business. `eq(get(...), ...)` // builds an expression - data, like the rest of the flow. company: step<{ name: string }>({ label: 'Your company', when: eq(get('data.ticket.kind'), 'business'), }), // The `guest` flow, once per guest, keyed by id - and skipped entirely // while there are none, which a repeat is required to say. guests: group({ label: 'Guests', flow: 'guest', when: not(empty(get('data.ticket.guests'))), repeat: { over: get('data.ticket.guests'), keyBy: 'id' }, }), // Entered only once the agenda is in: the move waits on `load`, and the // step is never current with nothing to pick from. sessions: step<{ picks: string[] }>({ label: 'Sessions', load: { $ref: 'agenda' }, }), review: step({ label: 'Review' }), },});import { createWizard } from '@wizzard-packages/core';import { groups } from '@wizzard-packages/core/groups';import { persist, type RestoreOutcome, type SyncStorage } from '@wizzard-packages/plugins/persist';
import { guest, registration } from './flow';import { registry } from './registry';import { trail } from './trail';
export interface OpenOptions { /** `localStorage` when left out. */ storage?: SyncStorage; /** Told once, at start, whether a saved session came back and why not. */ onRestore?: (outcome: RestoreOutcome) => void; /** Told every step a move lands on. */ onStep?: (step: string) => void;}
/** * Where the engine is built. Every later step adds to this one call - a * registry, group traversal, plugins - so it lives in a file of its own. * * Walking a group is a separate entry point, so a flow without one does not * carry it; `subFlows` answers the name the group's `flow` gives. */export const openWizard = ({ storage, onRestore, onStep }: OpenOptions = {}) => createWizard({ flow: registration, registry, groups, subFlows: { guest }, // Writes the session on every change and reads it back at start. `version` // is the app's own: bump it when stored answers stop meaning what they did. plugins: [ persist({ key: 'registration', version: 1, ...(storage === undefined ? {} : { storage }), ...(onRestore === undefined ? {} : { onRestore }), }), ...(onStep === undefined ? [] : [trail(onStep)]), ], });import type { SyncStorage } from '@wizzard-packages/plugins/persist';
import { openWizard } from './wizard';
const kept = new Map<string, string>();const storage: SyncStorage = { getItem: (key) => kept.get(key) ?? null, setItem: (key, value) => { kept.set(key, value); },};
const landed: string[] = [];const wizard = openWizard({ storage, onStep: (step) => landed.push(step) });
await wizard.start();wizard.set('attendee', { name: 'Ada', email: '' });// Refused by the validator: nothing committed, so nothing to report.await wizard.next();wizard.set('attendee.email', 'ada@example.com');await wizard.next();wizard.set('ticket', { kind: 'standard', guests: [{ id: 'g1', name: 'Grace' }] });await wizard.next();await wizard.next();await wizard.next();
console.log(`landed on: ${landed.join(' -> ')}`);A test in the repository asserts these exact lines.
landed on: attendee -> ticket -> badge -> diet -> sessionsBoth renderings are mounted in the browser only, because the session lives in the browser: a server rendering the first step while the browser restores a later one is a flash of the wrong page. Restore after reload explains why in full.
Where to go next
Section titled “Where to go next”What you can build is the next size up: a sub-flow that branches inside a repeat, a guard on entering a step, and a session written two frames deep and read back into a different engine. Concepts and glossary defines every word this page used, and every export is in the API reference.