Skip to content

Navigation

Every move through a flow is asynchronous and every move can be refused. That pair is the whole API surface of navigation, and it is deliberate: validation may need the network, a plugin may veto, and a user who clicks twice must not end up two steps along.

// `wizard` is the engine `createWizard` returns, or `useWizard()` in a component.
const result = await wizard.next();
if (!result.ok) {
// result.reason says why
}
Method Signature Moves to
start() () => Promise<NavResult> The first step the definition includes.
next(opts?) (opts?: { validate?: boolean }) => Promise<NavResult> Whatever on.next or order says is next.
back() () => Promise<NavResult> Whatever on.back says, or the previous reachable step in order.
go(to, opts?) (to: StepId | END, opts?: { validate?: boolean; force?: boolean }) => Promise<NavResult> A named step, if policy allows.
cancel() () => void Nowhere. It abandons the move in flight.

validate: false skips the step’s validator for that move. force: true on go skips the navigation policy - the rule that decides which steps a jump may land on - and skips nothing else. Both exist for the cases a product genuinely needs, a “save and exit” that must not be blocked and an admin opening a step to reproduce a bug, and both are the wrong default, which is why they are opt-in per call.

Guards are not among them. guards.enter and guards.exit run on every move, force included, and refuse with reason: 'blocked'. A guard is where “this user may not see this step” lives, and an option that switched it off would make it advice rather than a rule.

start() is next() from an empty stack. Once it has landed - a step is current, or a flow with no reachable step has finished - calling it again is not a move: it answers where the wizard already is. Until then it is not, so a first move that threw or was refused leaves the wizard unstarted, and the next start() tries again. A session restored onto a step that loads has not landed in this page yet: start() enters that step again, in place, so its load runs, and is idempotent once that entry or any other move has committed. A start() called while a move is still on its way does not step over it: it waits for that move, then answers from wherever the move left the wizard, running the first move itself if it left it unstarted.

back() resolves its target from the definition rather than from where the user has been, in whichever flow the wizard stands - the root, or a sub-flow inside a group. A step with on.back goes where its first applicable entry leads, read as on.next is. Otherwise the resolver walks order backwards and takes the first step whose when still holds, so a branch that closed behind the user is skipped rather than revisited. 'auto' is that same walk, written out, and can close a list of conditional entries.

The history the wizard keeps is a different thing. It records the stack at each move, which is what restores a position inside nested groups on the way back - into a group from the step after it as well, where its last item stopped; it does not choose the target. The distinction shows after a forced jump: go('summary', { force: true }) from the first step and then back() lands on whatever precedes summary in order, not on the step the jump came from.

A move returns a discriminated union, and the failing half carries the reason rather than an exception:

type NavResult =
| { ok: true; from: string | null; to: string | typeof END }
| {
ok: false;
reason: NavReason;
code: `nav-${NavReason}`;
url: string;
by?: string;
errors?: Record<string, string>;
};

code is the reason with a nav- prefix, and url is the page that explains it: what causes the refusal and what to do about it. Log the url beside an unexpected refusal and the reader lands on the explanation.

reason What happened
invalid The step’s validator refused. errors carries the field messages.
blocked A guard, a plugin or the navigation policy refused. by names the step or the plugin.
no-target There was no step to move to: back() on the first step, or go() to an unknown id.
not-reachable The target’s when is false, so it is not part of the flow right now.
superseded Another move started before this one finished. The later move wins.
aborted cancel() was called while this move was in flight.

superseded is the one worth designing for. Navigation carries an epoch: when a second next() begins, the first is stamped stale, and whatever it was awaiting cannot commit when it finally resolves. A double-clicked button produces one move and one refusal, never two moves.

superseded and aborted are checked after every await, before what the awaited step said, so they win over it: a move that was overtaken or cancelled while its validator ran answers that, not invalid, and writes no errors, and one whose plugin hook, validator, guard or loader threw by then answers that rather than throwing.

Nothing here throws to signal a refused move. A refusal is a value with a reason, because a refused move is an expected outcome of a wizard - the user has not filled the field yet - and an exception would force every call site into a try block for the normal case.

Exceptions are still thrown for programming errors: an unknown resolver, a group step with no traversal installed. Those are bugs in the wiring, not outcomes of a user’s click.

The bindings expose the same four methods plus the state a control needs to render itself. useNavigation is a named import from @wizzard-packages/react or @wizzard-packages/vue, and is called in a component under the provider:

const { next, back, canBack, isBusy, isLast } = useNavigation();

In React the three flags are booleans. In Vue they are ComputedRefs: a template unwraps them, and a script reads isBusy.value. The Steps component on Hooks uses them on both.

isBusy is true while a move is in flight, which is what a submit button should be disabled by - not a local flag, which cannot know about a move started elsewhere.

isLast is true when next() from the current step would finish the wizard, which is where a Next button usually turns into a Submit. It follows on.next, not the step’s place in order: a branch with no on.next finishes, and so does a step sent to '@end' (Flow has the rules).