Loading a step
Most steps are ready the moment the flow reaches them. A step that is not - one whose options come from an API, or whose body the server sends - says so, and the engine waits on entry instead of rendering something incomplete.
The example on this page is a booking with one step of each kind: seats waits on a resolver,
offer waits on a plugin. Every block below is a file from examples/tasks/src/async-steps in
the repository, and a test runs the script at the end and compares what it prints, so the output
shown is what the engine does. The files sit side by side in one folder; run.ts is run with a
TypeScript runner such as npx tsx run.ts. It awaits at the top level, so it runs as an ES
module, with "type": "module" in the folder’s package.json.
import { defineFlow, step } from '@wizzard-packages/core';
/** * A booking with two steps that cannot be shown the moment they are reached. * * `seats` has a `load`: the move waits on the named resolver before it lands. * `offer` is `deferred`: the definition holds no body for it, and the move * waits on every plugin's `loadStep` before it lands. */export const booking = defineFlow({ id: 'booking', order: ['passenger', 'seats', 'offer', 'review'], steps: { passenger: step<{ name: string }>({ label: 'Passenger' }), seats: step<{ seat: string }>({ label: 'Pick a seat', // `args` is plain JSON and reaches the resolver as its first argument. load: { $ref: 'seatMap', args: { plane: 'a320' } }, }), offer: step({ label: 'Your offer', deferred: true }), review: step({ label: 'Review' }), },});api.ts stands in for the server, so the example runs without one. Its first request for the
offer fails on purpose, which is how the run below shows a failed load.
import type { StepDef } from '@wizzard-packages/core';
/** * Stands in for the server. Timers rather than resolved promises, so every * answer arrives a whole task later - long after anything that did not wait * would have moved on. */const later = <T>(value: () => T, signal?: AbortSignal): Promise<T> => new Promise((settle, fail) => { if (signal?.aborted) { fail(signal.reason); return; } const timer = setTimeout(() => { try { settle(value()); } catch (error) { fail(error); } }, 10); signal?.addEventListener( 'abort', () => { clearTimeout(timer); fail(signal.reason); }, { once: true } ); });
export const fetchSeats = (plane: string, signal?: AbortSignal): Promise<string[]> => later(() => (plane === 'a320' ? ['1A', '1C', '2A'] : []), signal);
let offerCalls = 0;
/** How many times the offer was asked for - the run prints it. */export const offerRequests = (): number => offerCalls;
/** * The body of the `offer` step, as the server sends it. The first request * fails, the way a flaky network does, so the run can show what a failed load * leaves behind. */export const fetchOffer = (signal: AbortSignal): Promise<StepDef> => { offerCalls += 1; const failing = offerCalls === 1; return later(() => { if (failing) throw new Error('network down'); return { label: 'Upgrade to business for 120 EUR' }; }, signal);};load names a resolver run as the step is entered - seatMap on seats above. The resolver
lives in the registry, the object of named functions handed to createWizard as its registry
option (wizard.ts below):
import type { AsyncRegistry } from '@wizzard-packages/core';
import { fetchSeats } from './api';
/** * Where the seat map lands. `load` gates the step and discards what it * returns, so the resolver puts the answer somewhere itself - here, a module; * in an app, a store or a query cache. */export const seats: string[] = [];
/** Handed to `createWizard` as `registry`; `load: { $ref: 'seatMap' }` names this. */export const registry: AsyncRegistry = { // `args` is the definition's `args`, `scope` is where the target sits, and // `signal` aborts when the move is cancelled or the wizard destroyed. seatMap: async (args, _scope, signal) => { const { plane } = args as { plane: string }; seats.splice(0, seats.length, ...(await fetchSeats(plane, signal))); },};The resolver receives the args from the definition, the scope the target sits in, and the
move’s AbortSignal. cancel() aborts the latest move while it runs, and destroy() every move
still running, so a request handed the signal stops with them, and the move answers aborted
rather than throwing the request’s AbortError. A loader that has not started when
the move is called off never starts: neither load nor any loadStep, whether the move had not
reached its load yet or was waiting on an earlier loadStep. A newer move that overtakes this
one does not abort it, and a later cancel() reaches only that newer move - nothing at all once
it has settled. Inside a repeat group the scope is the target’s own, so loop.item is the item
being entered, not the one being left.
What it returns is discarded. load is a gate, not a fetch that fills the step: the engine
waits for the promise and moves on. Where the data lands is yours to decide - a store, a cache,
wizard.set from inside the resolver. A resolver load names but the registry does not hold
throws resolver-not-registered with op: 'load'.
load is one of the places built for waiting, so a promise here is expected;
resolver-is-async is about the places that are not: when,
a transition’s condition, repeat.over and a group’s input, all of which are evaluated
synchronously. A step’s guards are awaited, so a resolver behind guards.enter may be async.
A session restored before start() - by persist, or through createWizard({ state }) - is on
its step without having entered it, so the loader has not run in this page. start() enters such
a step again, in place, and runs its load; Persistence has the details.
deferred
Section titled “deferred”deferred: true marks a step whose body arrives from the host rather than from the definition -
offer above. Entering one calls loadStep(stepId, signal) on every plugin, in order, and waits
for each.
That is all deferred does: the engine awaits the plugins and continues. loadStep returns
Promise<void>, and the engine reads nothing back from it - a plugin that fetches a step body
installs it with patchFlow, which is also how a server-driven flow changes
shape while it runs.
patchFlow belongs to the wizard, not to the host a plugin’s init receives, and a plugin is
built before the wizard it is passed to. So the plugin takes a function that returns the wizard,
and calls it only from loadStep, when the wizard exists:
import type { Hooks, Wizard } from '@wizzard-packages/core';
import { fetchOffer } from './api';
/** * The plugin that fills the `deferred` step. `loadStep` returns nothing the * engine reads: the body is installed with `patchFlow`, the one way a flow * changes shape while it runs. * * `patchFlow` is a method of the wizard, not of the host `init` receives, and * the plugin is built before the wizard exists. So it is handed a function * that returns the wizard, and calls it only once a move is under way. */export const offers = (wizard: () => Wizard): Hooks => ({ name: 'offers', loadStep: async (stepId, signal) => { if (stepId !== 'offer') return; const body = await fetchOffer(signal); // Replaces the step by id. The new body no longer says `deferred`, so // coming back to the step later does not fetch it again. wizard().patchFlow({ steps: { offer: body } }); },});import { createWizard, type Wizard } from '@wizzard-packages/core';
import { booking } from './flow';import { offers } from './offers';import { registry } from './registry';
// The annotation is what lets the plugin's arrow refer to `wizard` before the// call that creates it has returned; the arrow runs only during a move.export const wizard: Wizard = createWizard({ flow: booking, registry, plugins: [offers(() => wizard)],});The installed body is the step from then on, the move that installed it included: once every
loadStep has finished, the engine reads the step again and checks the body, not the placeholder.
A body whose when does not hold for the data as the loaders left it - a loader may set what it
fetched first - is refused as not-reachable; otherwise its load runs next, and its enter guard
decides the entry, both reading that same data. A body the server sends without deferred is not
fetched again when the user comes back to the step; one that still says deferred: true is.
The body can be a group, when the wizard has groups installed. The move is then
resolved again on the flow as the loaders left it, so the wizard enters the group as it would one
that was there from the start: its first step, or the first item of a repeat, which goes through
its own when, load and enter guard before the move commits. A repeat with no items is walked
past to the step after it. The group’s own when is checked first, and refuses the move as above.
This applies to a step of the root flow. patchFlow replaces root steps only, so a deferred
step inside a group is entered as the move found it.
While it loads
Section titled “While it loads”The step being entered is added to busy, and isBusy is true while the move runs, which is
what disables a Next button without any state of your own.
The marker goes on every way out: with the commit that lands the move, and when a move never
commits - it is cancelled, refused, or its loader throws. A move a newer one overtakes loses its
marker the moment the newer move starts, and the newer move sets and clears its own. A button
bound to isBusy alone therefore comes back after a failed load as well as after a successful one.
A move that takes time can be overtaken. Every wait is followed by a check, and neither outcome is an exception:
| Result | Meaning |
|---|---|
nav-superseded |
a newer move started while this one waited; nothing was committed |
nav-aborted |
cancel() or destroy() stopped it; the signal load and loadStep hold was aborted too |
Both come back from next() as { ok: false, reason }. What they guarantee is narrow: the move
is not committed, so the wizard does not land on the step. A load resolver or a plugin’s
loadStep that was cancelled is handed an aborted signal and can stop its own work on it; one
that ignores the signal runs on, and whatever it writes to a store, a cache or through
wizard.set is written anyway. A superseded move aborts nothing: its loader finishes unless the
wizard is destroyed, and only its result is dropped.
A loader that throws is different: the exception is not a refusal. The wizard returns to idle on the
step it was on, and the promise from next() rejects with whatever the loader threw, for the caller
to catch and retry. A move that was overtaken or cancelled by then answers superseded or aborted
instead, and the failure, an AbortError included, is not rethrown. API
behaviour sets out that division.
The whole run
Section titled “The whole run”The script drives the booking through both kinds of loading, including the failed first request for the offer and the retry that follows it:
import { offerRequests } from './api';import { seats } from './registry';import { wizard } from './wizard';
await wizard.start();wizard.set('passenger', { name: 'Ada' });
// Waits for `seatMap`, then lands.await wizard.next();console.log(`on ${wizard.getSnapshot().current}, seats: ${seats.join(', ')}`);
// The first offer request fails. The loader's exception is not a refusal:// `next()` rejects with it, and the wizard is idle on the step it was on.try { await wizard.next();} catch (error) { console.log(`next() rejected: ${(error as Error).message}`);}console.log(`on ${wizard.getSnapshot().current}, busy: ${wizard.getSnapshot().isBusy}`);
// Retrying is calling `next()` again.await wizard.next();console.log(`on ${wizard.getSnapshot().current}: ${wizard.getFlow().steps['offer']?.label}`);
// Back and forward again: the installed body is no longer deferred.await wizard.back();await wizard.next();console.log(`offer requested ${offerRequests()} times`);
wizard.destroy();on seats, seats: 1A, 1C, 2Anext() rejected: network downon seats, busy: falseon offer: Upgrade to business for 120 EURoffer requested 2 times