Hooks
The engine is the same object in both bindings. A binding does one job: put that object where components can reach it, and turn its snapshot into something the framework re-renders on. The hook names are identical across React and Vue; what differs is what they hand back, because a React hook returns a value and a Vue composable returns a ref.
The example on this page is a two-step trip: a name, then a review. Every file shown in full is
from examples/tasks/src/hooks in the repository, and a test mounts it on both bindings and
drives it - a refused Next, a typed name, the move to the review, and an engine passed in that
the binding starts and leaves alive. The flow and its one validator are shared by both:
import { defineFlow, step } from '@wizzard-packages/core';
/** Two steps: name the trip, then look it over. */export const trip = defineFlow({ id: 'trip', order: ['details', 'review'], steps: { details: step<{ name: string }>({ label: 'Trip details', validate: { $ref: 'tripNamed' }, }), review: step({ label: 'Review' }), },});import type { AsyncRegistry } from '@wizzard-packages/core';
/** A validator returns a message per field that is wrong, or `null` when the step may be left. */export const registry: AsyncRegistry = { tripNamed: (_args, scope) => { const name = (scope.data['details'] as { name?: string } | undefined)?.name ?? ''; return name.trim() === '' ? { name: 'Give the trip a name.' } : null; },};Providing the wizard
Section titled “Providing the wizard”import { WizardProvider } from '@wizzard-packages/react';
import { trip } from './flow';import { registry } from './registry';import { Steps } from './Steps';
/** * The provider takes the options of `createWizard`, builds the engine on first * render, starts it on mount and destroys it on unmount. Every hook below it * reads that engine; outside it they throw `provider-missing`. */export function App() { return ( <WizardProvider flow={trip} registry={registry}> <Steps /> </WizardProvider> );}WizardProvider takes the options of createWizard - flow, registry, plugins, data,
ctx, state, groups, subFlows - and builds the engine itself, or takes an engine you
built with wizard. Passing both throws
provider-wizard-and-options: options beside a
built engine would be silently ignored.
<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Steps from './Steps.vue';import { trip } from './flow';import { registry } from './registry';
// Builds the engine, starts it in `onMounted` and destroys it with this scope.// Vue's inject reads the parent chain, so the component that provides cannot// use the composables itself: they go in a child, `Steps.vue`.provideWizard({ flow: trip, registry });</script>
<template> <Steps /></template>The component that calls provideWizard cannot use the composables itself: Vue’s inject reads
the parent chain, not the component’s own provides. The composables go in a child - here
Steps.vue - and called in App.vue they throw provider-missing.
provideWizard takes the options of createWizard - flow, registry, plugins, data,
ctx, state, groups, subFlows - and builds the engine, or takes an engine you built and
provides that one. An engine it created is destroyed with the scope, so plugin teardown runs;
one you passed in stays yours to destroy.
Each is what its framework expects - a component in React, a setup function in Vue - and that asymmetry is deliberate. Everything below it is the same in both.
The wizard is started on mount. If that first move throws, which a first step whose loader
rejects will do, there is no caller to throw to: the binding logs
start-failed with the cause and no step is current. The wizard
has not started, so useWizard().start() runs the first move again - from a retry button - and
so does mounting again.
On the server, mount never runs. A wizard rendered by SSR navigates nowhere and hydrates into its first step on the client.
The hooks
Section titled “The hooks”Each one is a narrower view of the same snapshot. Take the narrowest that answers the question: a component that reads one field re-renders when that field changes, and one that reads the whole snapshot re-renders on every commit.
| Hook | React returns | Vue returns |
|---|---|---|
useStep() |
current, index, isFirst, isLast, progress, breadcrumbs, active, status |
the same fields, each a ComputedRef |
useNavigation() |
next, back, go, cancel, and canBack, isBusy, isLast as booleans |
the same methods, and the three flags as ComputedRefs |
useField<T>(path) |
[value, setValue] |
a WritableComputedRef, for v-model |
useErrors(stepId?) |
the error map of a step, or of the current one | that map as a ComputedRef |
useWizardSelector(fn, isEqual?) |
one derived value | one derived value as a ComputedRef |
useWizardSnapshot() |
the whole snapshot | the snapshot as a Ref |
useWizard() |
the engine | the engine |
useOptionalWizard() |
the engine, or null outside a provider |
the same |
Every hook is a named import from @wizzard-packages/react or @wizzard-packages/vue. The
Steps component the providers above render uses the five a form needs; the sections after it
take them one at a time.
import { useErrors, useField, useNavigation, useStep, useWizardSelector,} from '@wizzard-packages/react';
/** The five hooks a form needs, in one component rendered inside the provider. */export function Steps() { // The stepper: where the wizard is, and how far along, from 0 to 100. const { current, progress, breadcrumbs } = useStep(); // The moves, and the flags a set of buttons needs. const { next, back, canBack, isBusy } = useNavigation(); // One field, by the dot path the flow uses: `data.details.name`. const [name, setName] = useField<string>('details.name'); // The messages validation produced for the current step. const errors = useErrors(); // One derived value; the component re-renders only when it changes. const length = useWizardSelector( (s) => (s.data['details'] as { name?: string } | undefined)?.name?.length ?? 0 );
// Nothing is current until the provider has started the engine. if (current === null) return null;
return ( <section> <ol> {breadcrumbs.map((b) => ( <li key={b.id} aria-current={b.status === 'current' ? 'step' : undefined}> {b.label} </li> ))} </ol> <p>{progress}% done</p>
{current === 'details' && ( <label> Trip name <input value={name ?? ''} onChange={(e) => setName(e.target.value)} /> <small>{length} characters</small> </label> )} {errors['name'] !== undefined && <p role="alert">{errors['name']}</p>} {current === 'review' && <p>Ready to book {name}.</p>}
<button onClick={() => void back()} disabled={!canBack || isBusy}> Back </button> {/* A refusal is the value `next()` resolves to; the errors above show it. */} <button onClick={() => void next()} disabled={isBusy || current === 'review'}> Next </button> </section> );}<script setup lang="ts">import { useErrors, useField, useNavigation, useStep, useWizardSelector,} from '@wizzard-packages/vue';
// The five composables a form needs. Each field is a `ComputedRef`, so// destructuring keeps it reactive.const { current, progress, breadcrumbs } = useStep();const { next, back, canBack, isBusy } = useNavigation();// A `WritableComputedRef`, for `v-model`.const name = useField<string>('details.name');const errors = useErrors();const length = useWizardSelector( (s) => (s.data['details'] as { name?: string } | undefined)?.name?.length ?? 0);</script>
<template> <!-- Nothing is current until `provideWizard` has started the engine. --> <section v-if="current !== null"> <ol> <li v-for="b in breadcrumbs" :key="b.id" :aria-current="b.status === 'current' ? 'step' : undefined" > {{ b.label }} </li> </ol> <p>{{ progress }}% done</p>
<label v-if="current === 'details'"> Trip name <input v-model="name" /> <small>{{ length }} characters</small> </label> <p v-if="errors['name'] !== undefined" role="alert">{{ errors['name'] }}</p> <p v-if="current === 'review'">Ready to book {{ name }}.</p>
<button :disabled="!canBack || isBusy" @click="back()">Back</button> <!-- A refusal is the value `next()` resolves to; the errors above show it. --> <button :disabled="isBusy || current === 'review'" @click="next()">Next</button> </section></template>Drawing a step
Section titled “Drawing a step”useStep is what a stepper needs and nothing else. It compares its fields one by one, including
the status of each breadcrumb, so a step turning invalid repaints the stepper.
const { current, progress, breadcrumbs } = useStep();<script setup lang="ts">const { current, progress, breadcrumbs } = useStep();</script>
<template> <progress :value="progress" max="100" /> <p>{{ current }}</p></template>Each field is a ComputedRef, so destructuring keeps it reactive - unlike destructuring a
reactive object.
Moving
Section titled “Moving”useNavigation hands back the engine’s four moves unchanged and the three flags a set of
buttons needs. Navigation is async and returns a result, not a boolean:
await next() gives { ok: false, reason: 'blocked', by: 'age-check' } when a guard refuses.
Navigation has the full list of refusals.
Reading and writing a field
Section titled “Reading and writing a field”useField addresses the same dot paths the flow does, so data.details.name in an expression is
'details.name' here.
const [name, setName] = useField<string>('details.name');
<input value={name ?? ''} onChange={(e) => setName(e.target.value)} />;<script setup lang="ts">const name = useField<string>('details.name');</script>
<template> <input v-model="name" /></template>A path can name the item a repeat is on - `guests.${key}.badge` - and then it is a
different path on the next item. In React pass the new path on the render that has it; the hook
reads and writes whatever path the latest render gave it. In Vue useField takes its path once,
in setup, so a field for the current item goes in a component keyed by that item, or reads and
writes through useWizardSelector and wizard.set with the key taken inside the getter, as the
Tutorial does.
Errors
Section titled “Errors”useErrors() reads the messages of the current step, useErrors('payment') those of a named
one. The map is the one validation produced, so a field component does not need the result of
the call that created it. Render field errors is the whole pattern.
One derived value
Section titled “One derived value”useWizardSelector is the escape hatch for anything the hooks above do not cover, and the way
to keep a large tree from re-rendering on every commit:
const cost = useWizardSelector((s) => s.data.cart.total);In React it takes a second argument, an equality function, which defaults to Object.is. Give
it one when the selector returns a fresh object each time, or the component re-renders on every
commit. Vue has no such argument: the ComputedRef is recomputed once per commit, so a selector
building a fresh object hands its watchers a new identity each time. Select a primitive, or
compare in the watcher.
The engine itself
Section titled “The engine itself”useWizard() is the engine: set, get, validate, setErrors, watch, select,
subscribe, batch, patchFlow and the rest. Typing it against a flow -
useWizard<typeof trip>() - types go, get and set against that flow’s steps and data.
Outside a provider useWizard() and every hook built on it throw
provider-missing. useOptionalWizard() returns null there
instead, which is what a component rendered both inside and outside a wizard needs - a
diagnostic panel, say, which should report that it has nothing to watch rather than crash the
application it is watching.
Owning the engine yourself
Section titled “Owning the engine yourself”The provider builds the engine by default, but it does not have to. Hand it one you built and
the object is yours: it exists before the tree mounts, it survives the tree unmounting, and
destroy() is never called for you - the binding disposes only an engine it created itself.
import { createWizard } from '@wizzard-packages/core';
import { trip } from './flow';import { registry } from './registry';
/** * An engine built outside the component tree. It exists before anything * renders and outlives the components that show it: unmounting them leaves it * where it was, so the user comes back to the same step. The binding starts it * on mount and never destroys it - `destroy()` is yours, when the wizard is no * longer needed, and a plugin's teardown runs there. */export const wizard = createWizard({ flow: trip, registry });import { WizardProvider } from '@wizzard-packages/react';
import { Steps } from './Steps';import { wizard } from './wizard';
/** * Given `wizard`, the provider takes no other options: they were given to * `createWizard` already, and passing them here throws * `provider-wizard-and-options`. */export function Owned() { return ( <WizardProvider wizard={wizard}> <Steps /> </WizardProvider> );}<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Steps from './Steps.vue';import { wizard } from './wizard';
// Given an engine, `provideWizard` provides that one and leaves its lifetime to you.provideWizard(wizard);</script>
<template> <Steps /></template>One thing the binding still does with an engine you passed: it starts it on mount, because a
wizard with an empty stack has no step to render. start() is idempotent, so a wizard you
already started is left where it is.
Reach for this when the wizard has to outlive the components that show it - a flow that continues across routes, one built from a server response before anything renders, or a test that drives the engine directly and mounts a component to look at it.
Without a provider at all
Section titled “Without a provider at all”The hooks read the wizard from React context or Vue’s injection, and there is no argument that
passes one in: outside a provider they throw. The engine itself needs neither. It is an external
store - subscribe returns an unsubscribe, and getSnapshot returns the same object until a
commit - so a component can read one directly with what its framework already has. Three things
have to happen, and with no provider each of them is yours: create the engine, start it, and
destroy it when it is no longer needed.
1. Create the engine where it should live. A module-level engine is one wizard for the whole
page, shared by every component that imports it, and it lives until the page is gone - which is
why nothing here calls destroy().
import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';import { registry } from './registry';
export const wizard = createWizard({ flow: signup, registry });2. Subscribe to it. useSyncExternalStore is the whole bridge. The third argument is the
server snapshot, and passing the same getSnapshot is right because the engine has not started
there: the server renders the empty state and the client hydrates into it.
import type { Wizard } from '@wizzard-packages/core';import { useSyncExternalStore } from 'react';
export function useSnapshot(wizard: Wizard) { return useSyncExternalStore(wizard.subscribe, wizard.getSnapshot, wizard.getSnapshot);}It takes the engine rather than importing one, so the same bridge works for the module-level
wizard above and for a wizard owned by one screen, below. A wizard that changes re-subscribes,
because subscribe is a new function then.
3. Start it, and render. start() is what enters the first step - without it current is
null and there is nothing to draw. It is idempotent and asynchronous, it must not run on the
server, and it rejects when the first step’s loader fails, so it goes in an effect with a
catch. This is the part the provider does for you.
import { useEffect } from 'react';
import { useSnapshot } from './useSnapshot';import { wizard } from './wizard';
export function Steps() { const { current, canBack, isBusy } = useSnapshot(wizard);
useEffect(() => { wizard.start().catch(console.error); }, []);
if (current === null) return null;
return ( <div> <h2>{current}</h2> <button onClick={() => void wizard.back()} disabled={!canBack || isBusy}> Back </button> <button onClick={() => void wizard.next()} disabled={isBusy}> Next </button> </div> );}When the wizard belongs to one screen rather than the page, it is created in useState so
a re-render does not build a second one, destroyed in the effect’s cleanup - a plugin’s teardown
runs there, and an engine nobody destroys keeps its subscriptions - and rebuilt when it has been
destroyed. That last branch is for StrictMode, which in development mounts, unmounts and mounts
again while useState keeps the same value: without it the second mount is handed a torn-down
engine, and start() does not bring one back. WizardProvider carries the same three lines,
which is the clearest argument for using it instead.
export function Screen() { const [wizard, setWizard] = useState(() => createWizard({ flow: signup, registry })); const { current } = useSnapshot(wizard);
useEffect(() => { if (wizard.isDestroyed()) { setWizard(createWizard({ flow: signup, registry })); return; } wizard.start().catch(console.error); return () => wizard.destroy(); }, [wizard]);
return current === null ? null : <h2>{current}</h2>;}1. Create the engine where it should live. A module-level engine is one wizard for the whole
page, shared by every component that imports it, and it lives until the page is gone - which is
why nothing here calls destroy().
import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';import { registry } from './registry';
export const wizard = createWizard({ flow: signup, registry });2. Subscribe to it. A shallowRef holding the snapshot invalidates exactly once per commit,
because the engine hands back the same object until one happens. Unsubscribe with the scope, or
a component that has gone leaves a listener behind.
import type { Wizard } from '@wizzard-packages/core';import { onScopeDispose, shallowRef } from 'vue';
export function useSnapshot(wizard: Wizard) { const snapshot = shallowRef(wizard.getSnapshot()); const stop = wizard.subscribe(() => { snapshot.value = wizard.getSnapshot(); }); onScopeDispose(stop); return snapshot;}It takes the engine rather than importing one, so the same composable works for the module-level wizard above and for a wizard owned by one screen, below.
3. Start it, and render. start() is what enters the first step - without it current is
null and there is nothing to draw. It is idempotent and asynchronous, it must not run on the
server, and it rejects when the first step’s loader fails, so it goes in onMounted with a
catch. This is the part provideWizard does for you.
<script setup lang="ts">import { onMounted } from 'vue';
import { useSnapshot } from './useSnapshot';import { wizard } from './wizard';
const snapshot = useSnapshot(wizard);
onMounted(() => { wizard.start().catch(console.error);});</script>
<template> <div v-if="snapshot.current"> <h2>{{ snapshot.current }}</h2> <button :disabled="!snapshot.canBack || snapshot.isBusy" @click="wizard.back()">Back</button> <button :disabled="snapshot.isBusy" @click="wizard.next()">Next</button> </div></template>When the wizard belongs to one screen rather than the page, create it in the component’s setup, subscribe to that one, and destroy it with the scope - a plugin’s teardown runs there, and an engine nobody destroys keeps its subscriptions:
<script setup lang="ts">import { createWizard } from '@wizzard-packages/core';import { onMounted, onScopeDispose } from 'vue';
import { useSnapshot } from './useSnapshot';
const wizard = createWizard({ flow: signup, registry });const snapshot = useSnapshot(wizard);
onMounted(() => void wizard.start().catch(console.error));onScopeDispose(() => wizard.destroy());</script>That is what a provider is: those three responsibilities plus the context that carries the engine to components you did not pass it to. Taking them on by hand costs about twenty lines and the hooks above stop working, so reach for this only when context genuinely cannot be used - a wizard read from outside the component tree, or one component watching two wizards at once.