Skip to content

API behaviour

The guides explain one concept each. This page is the other view: every rule that decides what a call does, in one table, so you can check a belief without reading four pages.

Rule Where to see it running
go(to) moves only where policy allows, which is visited unless the flow says otherwise. force: true skips that check and nothing else. Navigation
Guards always run, force included. A guard, a plugin and the policy all refuse with blocked, and by names the step or the plugin. Navigation
Validation is its own switch: validate: false skips the step’s validator for that move. Block Next until valid
back() takes on.back when the step sets one, and otherwise walks order backwards. Navigation
set(path, value) replaces the value at that path. patch(partial) merges shallowly into data. The flow
validate(step?) runs one step’s validator and resolves to a boolean. Validation
Data is kept when a branch is abandoned, unless the step declares clearOnLeave. Clear abandoned branch data
repeat items are keyed by keyBy, and by position when it is absent. The flow
The provider creates one engine and keeps it across rerenders. It never starts on the server. Getting started

The engine createWizard returns, whole. The bindings’ hooks are views over this object, and useWizard() hands you the object itself.

Method Does
start() Enters the first reachable step, or a restored step that loads. Then idempotent.
next(opts?) Forward, by on.next or by order. { validate: false } skips the leaving validator.
back() Backward, by on.back or by order.
go(to, opts?) Jumps, if policy allows it or force: true is given. to may be END.
cancel() Aborts the latest move and the signal its loaders hold. A load not yet begun never starts.
get(path) Reads data at a dot path. undefined before anything is written there.
set(path, value) Replaces the value at that path.
patch(partial) Merges shallowly into data.
setCtx(ctx) Replaces the context - the values a flow reads as ctx.* and never edits.
batch(fn) Runs several writes and notifies once.
reset(data?) Returns the wizard to its starting state, with new data if given.
validate(stepId?) Runs one step’s validator. Resolves to a boolean; messages land in state.
setErrors(stepId, errors) Sets or clears a step’s messages by hand. null clears.
getSnapshot() State and derived values in one object, identity-stable until the next commit.
getState() The raw state, without the derived values.
getFlow() The definition in force now, which patchFlow may have changed.
subscribe(listener) Calls back on every commit. Returns the unsubscribe.
select(fn, listener, equals?) Calls back only when the selected value changes.
watch(path, listener) Calls back only when the value at that data path changes.
patchFlow(patch) Replaces steps by id. Returns whether it applied; refuses a patch removing the current step.
destroy() / isDestroyed() Stops the engine and runs plugin teardowns; reports whether that happened.

Navigation methods resolve to a NavResult, never a boolean. subscribe, select and watch each return the function that unsubscribes them.

Option Default Without it
flow required -
registry none a $ref anywhere in the flow throws resolver-not-registered when run
plugins [] no hooks run
data {} the wizard starts empty
ctx {} ctx.* in expressions is undefined
state a fresh starting state nothing is restored; this is where a decoded snapshot goes
groups none a flow containing a group step throws groups-not-installed
subFlows none a group naming its flow by string cannot resolve it

registry, plugins and state hold functions or live state, which is why a flow stays serializable and its options do not. Groups covers the last two, Persistence the third.

force, validate and guards are read as one idea - “let me through” - and they are three, with three different answers.

force: true on go() skips the navigation policy: the rule that decides which steps a jump may land on. A flow that names no policy gets visited, so what the policy usually stops is someone opening a step they have never been to. That is all force skips. It is there for the cases a product genuinely has, an admin opening a step to reproduce a bug among them.

validate: false skips the step’s own validator for that one move. A “save and exit” that must not be blocked by a half-filled field is what it is for.

Guards have no switch. guards.enter and guards.exit run on every move, force included, and a refusal comes back as { ok: false, reason: 'blocked', by }. This is deliberate: a guard is where “this user may not see this step” is written, and an option that turned it off would make it a suggestion. If a guard is refusing a move you want to allow, the condition is wrong - change the condition.

The two refusals are not told apart by their reason. The policy and a guard both come back as blocked naming the target step, so reading the reason cannot tell you which one stopped the move. What tells you is which change makes it stop refusing.

Try it. The jump below is refused twice for two different causes, and the two causes need two different answers: force for the policy, a plan for the guard. Press the buttons in order, then type a plan and press them again.

Nothing tried yet.

Current step: plan

Three steps so that `done` is not adjacent, and one guard that asks about the data.

flow.ts
import { empty, get, not } from '@wizzard-packages/core/expr';
import { defineFlow, step } from '@wizzard-packages/core';
/**
* Three steps and one guard, arranged so that the three things people expect to
* be one switch can be told apart.
*
* The flow keeps the default `sequential` policy, so jumping from `plan` to
* `done` skips a step and the policy refuses the move. `force` skips that
* check - and then meets the guard on `done`, which is a different rule with no
* switch of its own. Choosing a plan is what finally opens the step, because
* the guard was never about the policy.
*/
export const signup = defineFlow({
id: 'guards-and-force',
order: ['plan', 'review', 'done'],
steps: {
plan: step<{ choice: string }>({ label: 'Choose a plan' }),
review: step({ label: 'Review' }),
done: step({
label: 'Done',
// A guard is an expression, evaluated against the state of the moment.
// This one says the step cannot be entered until a plan exists.
guards: { enter: not(empty(get('data.plan.choice'))) },
}),
},
});

Two buttons, one difference: the second passes `force`.

App.tsx
import {
WizardProvider,
useField,
useNavigation,
useStep,
useWizard,
} from '@wizzard-packages/react';
import { useId, useState } from 'react';
import { signup } from './flow';
export function App() {
return (
<WizardProvider flow={signup}>
<Wizard />
</WizardProvider>
);
}
/** The last answer navigation gave, kept in the words it gave it. */
type Outcome = { ok: true } | { ok: false; reason: string; by?: string } | null;
const describe = (outcome: Outcome): string => {
if (outcome === null) return 'Nothing tried yet.';
if (outcome.ok) return 'Moved to Done.';
return outcome.by === undefined
? `Refused: ${outcome.reason}.`
: `Refused: ${outcome.reason}, by ${outcome.by}.`;
};
export function Wizard() {
const wizard = useWizard();
const { current } = useStep();
const { isBusy } = useNavigation();
const [choice, setChoice] = useField<string>('plan.choice');
const [outcome, setOutcome] = useState<Outcome>(null);
// Generated, because both renderings of this example sit in the page at once.
const choiceId = useId();
const step = current ?? 'plan';
const starting = current === null;
const jump = (force: boolean) => {
void wizard.go('done', { force }).then((result) => {
setOutcome(result.ok ? { ok: true } : { ok: false, reason: result.reason, by: result.by });
});
};
return (
<form onSubmit={(e) => e.preventDefault()}>
<p>
<label htmlFor={choiceId}>Your plan</label>
<input
id={choiceId}
value={choice ?? ''}
onChange={(e) => setChoice(e.target.value)}
disabled={starting}
placeholder="leave it empty"
/>
</p>
{/* Two buttons, one difference: the second passes `force`. Neither of
them can get past the guard on `done`. */}
<button type="button" onClick={() => jump(false)} disabled={starting || isBusy}>
Jump to Done
</button>
<button type="button" onClick={() => jump(true)} disabled={starting || isBusy}>
Jump with force
</button>
<p role="status">{describe(outcome)}</p>
<p>Current step: {step}</p>
</form>
);
}

There is no validateAll(). validate(step?) checks the current step or a named one, and checking several means calling it for each. The engine validates a step when it is left, so a whole-flow check is a question about steps nobody has reached yet, and the answer would go stale before it was read.

None of the rules above throw. A refused move returns { ok: false, reason } and the flow stays where it was, because a refusal is an ordinary outcome of a wizard rather than a failure of the program. Navigation lists every reason and what each one means.

What does throw is a mistake in the program: a $ref the registry does not hold, a group step with no traversal installed, useWizard called outside a provider, a WizardProvider given both a built wizard and options it would ignore. Every one of them is a WizardError, exported from @wizzard-packages/core, and every one carries the same fields:

Field What it holds
code a stable kebab-case name, such as resolver-not-registered
op the public operation that failed: evaluate, createWizard, useWizard
path where in the flow, when there is a where: steps.account
fix what to change, as a sentence
url the page for that code, such as /errors/provider-missing

The message is those fields as four sentences - what went wrong, why, the fix, the page - so a failure read in a console says what to do next without a lookup. The url is built from the code alone and never carries a value the application passed in.

A move that was overtaken or cancelled before its failure surfaced answers superseded or aborted instead of throwing it; the next move that reaches the same mistake throws it.

error instanceof WizardError holds whichever entry threw it. An error from @wizzard-packages/core/validate-flow or /groups is caught by the class imported from /v1, though each entry is bundled on its own and carries its own copy.

Some failures happen where nothing called the library and nothing could catch a throw: inside a plugin’s hook or a listener, while the wizard is torn down, or when a provider starts the wizard on mount. Those are logged with console.error in the same four sentences, with what was thrown as the second argument, and the wizard carries on.

What failed What happens next Code
a plugin’s init threw, or its onCommit or onAttempt threw or rejected the plugin is switched off, reported once plugin-disabled
a plugin’s afterNavigate threw or rejected the move stands, the plugin stays on after-navigate-threw
a function passed to subscribe, select or watch threw or rejected the change stands, every other listener runs listener-threw
a teardown threw or rejected during destroy() the other teardowns still run plugin-teardown-failed
the provider’s first move threw on mount no step is ready until start() runs again start-failed
persist()’s onRestore threw or rejected the outcome stands, logged with console.warn persist-on-restore-threw

A plugin switched off gets none of its hooks again, including the rest of a move that was already under way when it failed.