Skip to content

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.

async-steps/flow.ts
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.

async-steps/api.ts
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):

async-steps/registry.ts
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: 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:

async-steps/offers.ts
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 } });
},
});
async-steps/wizard.ts
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.

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 script drives the booking through both kinds of loading, including the failed first request for the offer and the retry that follows it:

async-steps/run.ts
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();
Output
on seats, seats: 1A, 1C, 2A
next() rejected: network down
on seats, busy: false
on offer: Upgrade to business for 120 EUR
offer requested 2 times