Skip to content

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.

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.

flow.ts
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.

wizard.ts
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 });
run.ts
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(', ')}`);
Output
at: attendee
at: ticket
at: attendee
name: Ada
steps: attendee, ticket, review

Nothing 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.

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.

flow.ts
// ...
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' }),
},
// ...
registry.ts
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;
},
};
wizard.ts
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 });
run.ts
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)}`);
Output
refused: invalid {"email":"Enter an email address."}
at: attendee
errors on attendee: {"email":"Enter an email address."}
next: ok
at: ticket

A 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.

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.

flow.ts
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' }),
},
});
run.ts
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)}`);
Output
standard: attendee, ticket, review
at: review
business: attendee, ticket, company, review
at: company

The 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.

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.

flow.ts
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:

wizard.ts
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 } });
run.ts
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'))}`);
Output
no guests: attendee, ticket, review
two guests: attendee, ticket, guests, review
at: guests[g1] / badge
at: guests[g1] / diet
at: guests[g2] / badge
at: review
Grace eats: vegetarian

Where 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.

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.

flow.ts
// ...
// 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.

registry.ts
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');
},
};
run.ts
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(', ')}`);
Output
busy while loading: yes
shown before the agenda arrived: no
at: sessions
talks: Keynote, Flows as data, Testing wizards

While 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.

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:

Terminal window
pnpm add @wizzard-packages/core@canary @wizzard-packages/plugins@canary

In a browser it uses localStorage; the script hands it a Map behind the same two methods, so a reload can happen in one process.

wizard.ts
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 }),
}),
],
});
run.ts
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)}`);
Output
first visit: persist/nothing-stored
left on: ticket
after reload: restored
back on: ticket
name kept: Ada

onRestore 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.

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.

trail.ts
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));
},
});
wizard.ts
// ...
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)]),
],
});
run.ts
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(' -> ')}`);
Output
landed on: attendee -> ticket -> badge -> diet -> sessions

The 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.

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.

App.tsx
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.

guests.ts
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);
});
};

Both 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.

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.