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.
Install
Section titled “Install”Pick the binding you render with. The choice is remembered, here and on every other page.
In an app you already have
Section titled “In an app you already have”pnpm add @wizzard-packages/core@canary @wizzard-packages/react@canaryThe 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.
pnpm add @wizzard-packages/core@canary @wizzard-packages/vue@canarypnpm add @wizzard-packages/core@canaryThe engine has no framework in it, so core alone is a wizard you drive yourself.
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.
In a new app
Section titled “In a new app”Any bundler will do. These are the templates the examples are built with:
pnpm create vite my-wizard --template react-tscd my-wizardpnpm add @wizzard-packages/core@canary @wizzard-packages/react@canaryThen 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'.
pnpm create vite my-wizard --template vue-tscd my-wizardpnpm add @wizzard-packages/core@canary @wizzard-packages/vue@canaryThen put the three files below in src: flow.ts and Wizard.vue beside the template’s
files, and App.vue in place of the template’s. src/main.ts already mounts App.vue, so it
stays as it is.
pnpm create vite my-wizard --template vanilla-tscd my-wizardpnpm add @wizzard-packages/core@canaryThen put flow.ts and headless.ts in src, and replace the contents of src/main.ts with
import './headless';. pnpm dev serves the page; what the script prints is in the browser
console.
The quickstart
Section titled “The quickstart”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.
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.
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> );}The same file the React tab shows. One definition, two renderings.
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 parent provides the wizard. It has to be a parent: `inject` reads the parent chain, so the component that provides cannot also use the composables.
<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Wizard from './Wizard.vue';import { signup } from './flow';
// The wizard is provided here and consumed by the child. Vue's inject reads the// parent chain, so the component that provides cannot also use the composables.provideWizard({ flow: signup });</script>
<template> <Wizard /></template>The rendering, step for step the React file, with `v-if` where the other has `&&`.
<script setup lang="ts">import { useField, useNavigation, useStep } from '@wizzard-packages/vue';import { computed } from 'vue';
const { current, isLast } = useStep();const { next, back, canBack } = useNavigation();const full = 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 = computed(() => current.value ?? 'name');const starting = computed(() => current.value === null);</script>
<template> <form @submit.prevent> <label v-if="step === 'name'"> Your name <input v-model="full" :disabled="starting" /> </label> <p v-else-if="step === 'review'">Hello, {{ full || 'stranger' }}.</p>
<button type="button" :disabled="starting || !canBack" @click="back()">Back</button> <button type="button" :disabled="starting || isLast" @click="next()">Next</button> </form></template>The same definition again. Nothing in it knows about a framework.
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 engine driven by hand: start, set, next, back, get. A binding makes these four calls for you and renders the result.
import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';
/** * The same flow with no framework at all: the engine is a plain object, and * this file drives it the way a component would. * * Nothing here is a test helper. `start` enters the first step, `set` writes a * value at a path, `next` and `back` move, and `get` reads - the same four * calls the React and Vue bindings make on your behalf. */const wizard = createWizard({ flow: signup });
await wizard.start();console.log(`step: ${String(wizard.getSnapshot().current)}`);
wizard.set('name.full', 'Ada');await wizard.next();console.log(`step: ${String(wizard.getSnapshot().current)}`);console.log(`name: ${String(wizard.get('name.full'))}`);
// The point of the whole example: going back does not discard what was typed.await wizard.back();console.log(`step: ${String(wizard.getSnapshot().current)}`);console.log(`name after Back: ${String(wizard.get('name.full'))}`);What that script prints. A test in the repository asserts these exact lines, so this is not a transcript of a good run.
step: namestep: reviewname: Adastep: namename after Back: AdaThe flow
Section titled “The flow”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.
1. The definition
Section titled “1. The definition”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.
2. The code the definition names
Section titled “2. The code the definition names”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.
3. Creating and driving the wizard
Section titled “3. Creating and driving the wizard”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:
step: detailsnext: invalid {"details":{"email":"Enter an email address."}}step: companystep: paymentstep: detailsstep: paymentEach 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.
What is next
Section titled “What is next”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.