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 |
Every method on the wizard
Section titled “Every method on the wizard”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.
Every option createWizard takes
Section titled “Every option createWizard takes”| 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.
The three switches are not one switch
Section titled “The three switches are not one switch”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.
Three steps so that `done` is not adjacent, and one guard that asks about the data.
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`.
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> );}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'))) }, }), },});<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Wizard from './Wizard.vue';import { signup } from './flow';
provideWizard({ flow: signup });</script>
<template> <Wizard /></template><script setup lang="ts">import { useField, useNavigation, useStep, useWizard } from '@wizzard-packages/vue';import { computed, ref, useId } from 'vue';
const wizard = useWizard();const { current } = useStep();const { isBusy } = useNavigation();const choice = useField<string>('plan.choice');
/** The last answer navigation gave, kept in the words it gave it. */type Outcome = { ok: true } | { ok: false; reason: string; by?: string } | null;
const outcome = ref<Outcome>(null);
// Generated, because both renderings of this example sit in the page at once.const choiceId = useId();
const step = computed(() => current.value ?? 'plan');const starting = computed(() => current.value === null);
const described = computed(() => { const o = outcome.value; if (o === null) return 'Nothing tried yet.'; if (o.ok) return 'Moved to Done.'; return o.by === undefined ? `Refused: ${o.reason}.` : `Refused: ${o.reason}, by ${o.by}.`;});
const jump = async (force: boolean): Promise<void> => { const result = await wizard.go('done', { force }); outcome.value = result.ok ? { ok: true } : { ok: false, reason: result.reason, by: result.by };};</script>
<template> <form @submit.prevent> <p> <label :for="choiceId">Your plan</label> <input :id="choiceId" v-model="choice" :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" :disabled="starting || isBusy" @click="jump(false)">Jump to Done</button> <button type="button" :disabled="starting || isBusy" @click="jump(true)"> Jump with force </button>
<p role="status">{{ described }}</p> <p>Current step: {{ step }}</p> </form></template>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'))) }, }), },});Four attempts, because two of them look identical and are not.
import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';
/** * Four attempts, because two of them look identical and are not. * * The policy and the guard both refuse with `blocked`, so the reason alone * cannot tell them apart. What tells them apart is what makes each one stop * refusing: `force` for the policy, and the data for the guard. */const wizard = createWizard({ flow: signup });
const say = (label: string, result: { ok: boolean; reason?: string; by?: string }): void => { console.log(`${label}: ${result.ok ? 'ok' : `${result.reason} by ${result.by}`}`);};
await wizard.start();console.log(`step: ${String(wizard.getSnapshot().current)}`);
// 1. The policy refuses: this flow's default is `visited`, and nobody has been// to `done`.say('go', await wizard.go('done'));
// 2. `force` skips the policy. The guard is still there, and the plan is empty.say('go force', await wizard.go('done', { force: true }));
wizard.set('plan.choice', 'pro');
// 3. The guard is satisfied now - and the policy still is not, because `done`// is still a step nobody has visited.say('go after choosing', await wizard.go('done'));
// 4. Both answered: the policy by `force`, the guard by the data.say('go force after choosing', await wizard.go('done', { force: true }));console.log(`step: ${String(wizard.getSnapshot().current)}`);A test in the repository asserts these exact lines.
step: plango: blocked by donego force: blocked by donego after choosing: blocked by donego force after choosing: okstep: doneWhat is not here
Section titled “What is not here”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.
Refusals are values
Section titled “Refusals are values”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.
Failures are one class
Section titled “Failures are one class”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.
Failures with no caller are logged
Section titled “Failures with no caller are logged”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.