Skip to content

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:

hooks/flow.ts
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' }),
},
});
hooks/registry.ts
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;
},
};
hooks/App.tsx
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.

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.

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.

hooks/Steps.tsx
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>
);
}

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();

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.

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)} />;

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.

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.

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.

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.

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.

hooks/wizard.ts
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 });
hooks/Owned.tsx
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>
);
}

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.

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().

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

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

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

Screen.tsx
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>;
}

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.