Skip to content

Getting started

A flow is a definition: the order of steps, the conditions that include them, and the validation that guards them. The engine reads that definition; your components render whatever step it says the flow is on. Below is the whole of that, running, in about two minutes.

Pick the binding you render with. The choice is remembered, here and on every other page.

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

The binding is a client module: its build opens with 'use client'. Only a Server Components bundler, such as the Next.js App Router, reads that directive; in Vite, Remix, webpack or Node it does nothing. Under Server Components a server component can render WizardProvider directly with a flow; anything carrying functions - a registry, plugins, a built wizard - and the hooks belong in a client component.

The @canary tag is where v1 lives until the 1.0.0 release; it moves to latest then.

Supported: Node 20.11 or newer, TypeScript 5 or newer, React 18 or newer, Vue 3.5 or newer. Every package ships ESM and CJS, with types for both. react and react-dom are peer dependencies of the React binding and vue of the Vue one; core depends on nothing.

Any bundler will do. These are the templates the examples are built with:

Terminal window
pnpm create vite my-wizard --template react-ts
cd my-wizard
Terminal window
pnpm add @wizzard-packages/core@canary @wizzard-packages/react@canary

Then put the two files below in src: flow.ts beside the template’s files, and App.tsx in place of the template’s. The quickstart exports App by name and the template’s src/main.tsx imports it as a default, so change that one line to import { App } from './App.tsx'.

Two steps, one field, Next, Back - and the value is still there after Back. That last part is the whole reason this library exists, so it is what the smallest example proves. Type a name, move forward, come back.

The definition. Two steps and the data one of them collects; no functions, so it is JSON.

flow.ts
import { defineFlow, step } from '@wizzard-packages/core';
/**
* The smallest flow that is still a wizard: two steps, one field, and a value
* that has to survive going back.
*
* A flow is data. This object is JSON — no functions, no classes — so the same
* definition can come from a file, from a backend, or from a generator, and one
* engine runs all three.
*/
export const signup = defineFlow({
id: 'signup',
order: ['name', 'review'],
steps: {
name: step<{ full: string }>({ label: 'Your name' }),
review: step({ label: 'Review' }),
},
});

The rendering. The provider owns the engine; the component below it asks which step is current and draws that one.

App.tsx
import { WizardProvider, useField, useNavigation, useStep } from '@wizzard-packages/react';
import { signup } from './flow';
export function App() {
return (
<WizardProvider flow={signup}>
<Wizard />
</WizardProvider>
);
}
export function Wizard() {
const { current, isLast } = useStep();
const { next, back, canBack } = useNavigation();
const [full, setFull] = useField<string>('name.full');
// Nothing is current until the engine starts, which happens in the browser:
// on the server, and for the first paint, `current` is null. So the form
// draws the step it is about to enter and keeps every control out of reach
// until the engine can act on it - the field included, because anything typed
// before the engine exists is not in its state and the first commit would
// wipe it.
const step = current ?? 'name';
const starting = current === null;
return (
<form onSubmit={(e) => e.preventDefault()}>
{step === 'name' && (
<label>
Your name
<input value={full ?? ''} onChange={(e) => setFull(e.target.value)} disabled={starting} />
</label>
)}
{step === 'review' && <p>Hello, {full || 'stranger'}.</p>}
<button type="button" onClick={() => back()} disabled={starting || !canBack}>
Back
</button>
<button type="button" onClick={() => next()} disabled={starting || isLast}>
Next
</button>
</form>
);
}

A definition does more than order two steps. This one branches, guards a step with a named validator, and sends Back somewhere other than where it came from. It takes three files: the definition, the code it names, and the place the two are put together and driven. They go in src/branching/, so they sit beside the quickstart’s files rather than over them.

src/branching/flow.ts
import { defineFlow, step } from '@wizzard-packages/core';
/**
* The quickstart's next step up: a branch, a guarded step and a Back that
* does not retrace the way it came.
*
* Still JSON. The validator is only a name here, `{ $ref: 'needsEmail' }`;
* the function behind it lives in `registry.ts`, and the engine joins the two
* when it runs.
*/
export const signup = defineFlow({
id: 'signup',
order: ['details', 'company', 'payment'],
steps: {
details: step<{ email: string; payer: 'personal' | 'business' }>({
label: 'Details',
validate: { $ref: 'needsEmail' },
}),
company: step<{ name: string }>({
label: 'Company',
when: { $eq: [{ $get: 'data.details.payer' }, 'business'] },
}),
payment: step({ label: 'Payment', on: { back: 'details' } }),
},
});

Three things are true of this object before any of your code runs. company is part of the flow only while details.payer is business: when is evaluated against the data as it is now, so changing the answer adds or removes the step. details cannot be left until the validator named needsEmail lets it. And Back from payment lands on details, not on whatever came before it.

Each step<T>() declares what that step writes into data, under the step’s id - details writes data.details.email and data.details.payer - which is why when reads data.details.payer. step returns the object it is given; the type is for the compiler only.

src/branching/registry.ts
import type { AsyncRegistry } from '@wizzard-packages/core';
/**
* The code the flow names. A validator answers with the messages it found,
* keyed by field, or `null` when the step may be left.
*
* Not a boolean: `false` carries no message to show and does not stop the
* move. Only an object with at least one key refuses it.
*/
export const registry: AsyncRegistry = {
needsEmail: (_args, { data }) => {
const details = data['details'] as { email?: string } | undefined;
return details?.email ? null : { email: 'Enter an email address.' };
},
};

The definition stays JSON, so it cannot hold a function. It holds the name needsEmail, and the registry holds the function under that name. The engine joins them when it validates the step.

A validator answers with the problems it found, as messages keyed by field, or with null when the step may be left. It does not answer true or false: only an object with at least one key refuses the move, so a validator that returns false lets every move through and has no message to show. Validation has the whole contract.

src/branching/run.ts
import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';
import { registry } from './registry';
/**
* The definition and the registry meet here, and this file plays the part a
* component plays in an app: it starts the wizard, writes what a user would
* type, asks to move, and destroys the wizard when it is done with it.
*/
const wizard = createWizard({ flow: signup, registry });
await wizard.start();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
// Nothing typed yet, so `needsEmail` answers with a message and the move is refused.
const refused = await wizard.next();
console.log(
`next: ${refused.ok ? 'ok' : `${refused.reason} ${JSON.stringify(wizard.getState().errors)}`}`
);
// A business payer: `company` is part of the flow, so Next lands on it.
wizard.set('details.email', 'ada@example.com');
wizard.set('details.payer', 'business');
await wizard.next();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
await wizard.next();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
// `on.back` sends Back from `payment` to `details`, past `company`.
await wizard.back();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
// A personal payer: `company` drops out of the flow, and Next skips it.
wizard.set('details.payer', 'personal');
await wizard.next();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
wizard.destroy();

createWizard takes the definition and the registry and returns the engine. It is created idle, on no step. start() enters the first step, and from then on the engine moves only when it is asked to. In this file every call is made by hand. In an app, a binding’s provider creates the wizard and calls start() when it mounts, and your components call set from inputs and next and back from buttons. destroy() is the owner’s last call: it aborts every move still running, removes the listeners and runs each plugin’s teardown. A provider that created the wizard makes that call on unmount. A wizard you created yourself, here or handed to a provider, is yours to destroy when you are done with it.

To run it in the app from In a new app, add import './branching/run'; to src/main.ts - src/main.tsx in React - and start pnpm dev. The script prints to the browser console:

Output
step: details
next: invalid {"details":{"email":"Enter an email address."}}
step: company
step: payment
step: details
step: payment

Each line is one claim from the definition. The first next() is refused with the validator’s message, and the message is kept in state.errors under the step’s id. With a business payer Next goes to company. Back from payment skips company, because of on.back. Once the payer is personal, Next skips company, because its when no longer holds. A test in the repository runs this script and compares what it prints with the block above, so the output cannot fall out of date.

The Tutorial picks up where this page stops: a registration built one change at a time, from two steps to a branch, a repeat, a loading step, a saved session and a plugin.

The flow is the shape of that object in full - every field, and the two of them named validate. Expressions is the thirteen-operator language that when is written in. Navigation covers moving through a flow and the refusals a move can return instead. Validation separates checking a definition from checking a user’s answers, and Persistence covers writing a session out and refusing to restore one that no longer fits.

Concepts and glossary defines every word those pages are written in, once, and What you can build puts branching, a repeat, a sub-flow, a loading step and a restored session into one definition. Every export, with its signature, is in the API reference.