Skip to content

The flow

A flow is data. It has no methods, closes over nothing, and survives JSON.parse. That is the whole design: a definition can be written by hand, generated by a backend, stored in a column, diffed in a review, and checked before it ever reaches a user.

import { defineFlow } from '@wizzard-packages/core';

defineFlow returns its argument. It exists for inference, not for behaviour - the object you pass is the object the engine reads, and nothing is added to it.

Field Type Required Meaning
id string yes Names the flow. A snapshot records it and refuses to restore into a different one.
steps Record<string, StepDef> yes Every step in the flow, keyed by id.
order readonly string[] no The default sequence. Without it a step goes nowhere unless on.next says where.
version number no Bumped when the shape changes. Restoring an older snapshot is a decision, not an accident.
policy 'sequential' | 'visited' | 'free' no Which steps go() may jump to.
validate { on?, debounceMs? } no When validation runs, not what it checks.

The policy values differ in which jumps they permit. sequential allows a move to an adjacent step and no further - the one before or the one after, counted among the steps currently reachable and the branches taken, the same list the breadcrumbs draw. visited allows any step the user has already been to. free allows any step at all.

A flow that omits policy gets visited: someone may return to anything they have seen and cannot skip ahead into a step whose prerequisites never ran.

A step is one of two things, and which one it is depends on a single field: a step with flow is a group, everything else is an atom.

steps: {
details: { label: 'Details', validate: { $ref: 'needsEmail' } },
company: { label: 'Company', when: { $eq: [{ $get: 'data.payer' }, 'business'] } },
payment: { label: 'Payment', on: { back: 'details' } },
}

Both kinds share the same base:

Field Type Meaning
label string What a breadcrumb or a stepper shows.
when Expr The step is part of the flow only while this holds.
guards { enter?: Expr; exit?: Expr } Refuse entry or exit without removing the step.
on { next?: Target | Target[]; back?: BackTarget | BackTarget[] } Where this step leads.
slice string The subtree of data this step owns.
ui Json Passed untouched to your renderer, never evaluated.
load { $ref: string; args?: Json } A resolver run on entry.
deferred boolean The body arrives later: entering waits on loadStep.
clearOnLeave readonly string[] | true Data to drop when the step is left.

An atom step adds view (a name your renderer maps to a component) and validate, which is a { $ref } naming the validator that guards next(). A group step adds flow (a nested definition or the name of one), repeat for running that subflow over a list, and input for the values passed into it.

A repeat group runs its subflow once per item of over. Items are identified by keyBy - a path inside each item whose value becomes that item’s key - and by position when keyBy is absent. The key is what the frame stores, so a wizard halfway through the third passenger stays on that passenger when the list is sorted underneath it, and loop.key names them in an expression.

What a key does not do is move the answers. set() writes the literal path it is given, so items that all write to data.passenger.name share one value however they are keyed. Data belonging to an item has to be keyed by you: read loop.key and write beneath it.

It is kept. That is the default and it holds for a branch the user walked away from: a step whose when stops holding is off the route, but what it collected stays in data, because the engine cannot know whether you wanted it gone and someone who switches back expects their answers to still be there.

clearOnLeave is how a step says otherwise, and it applies whenever that step is left, forwards included. It does not run on completion - the last step’s data is the submission. Clear abandoned branch data shows both halves, and says what clearing does not protect.

They sit at different levels and mean different things, and confusing them is the most common misreading of this model.

  • FlowDefinition.validate is scheduling: { on: 'change' | 'blur' | 'next' | 'manual', debounceMs }. It decides when validation runs.
  • AtomStep.validate is a { $ref: string; args?: Json }. It decides what runs.

A flow can set the first and never the second, in which case there is nothing to run. The reverse is also legal: a step names a validator and the flow leaves the default schedule alone.

on.next accepts a step id, an object { to, when }, or an array of those - the first entry whose when holds, leading to a step whose own when holds, is taken. A transition that finds nothing to take is never skipped past, and the user stays where they are: when the entries that apply lead only to steps that are switched off, next() refuses with not-reachable naming the first of them, and when no entry applies at all, with no-target. To finish the wizard in that case, end the array with '@end'; to fall through to the next step, name it.

on.back reads the same way - the first entry whose when holds and whose step is reachable, not-reachable or no-target when none is - and accepts one more entry, the literal 'auto': walk order backwards and take the first step that is still reachable, which is also what a step without on.back does. Alone, 'auto' is that default written out; last in a list, it is where a conditional way back falls through to, as '@end' is for on.next. '@end' is not a way back.

A step does not have to be in order to be entered. One left out of it is a branch: an on.next that names it lands on it whenever its when holds, and so does a go() the navigation policy allows. next() and back() never walk into it on their own. Having no place in order, it also has no neighbours there: next() from it without an on.next finishes the wizard, and back() without an on.back answers no-target, so a branch usually sets both. The same holds for a group.

The derived values - active, index, isFirst, isLast, progress and the breadcrumbs - are built from order, with each branch the wizard took placed right after the step it was entered from: standing on one, or anywhere past it, counts it as a step and draws its breadcrumb. The path is history, so a branch drops out again when back() leaves it, when a go() returns to a step before it, or when its own when turns false. One never taken is not counted, and a step of order whose when turns false while it is the current one reads as index -1. sequential counts neighbours on the same list, so it never lets a go() into a branch not yet taken without force, and allows one to a taken branch next to the current step.

isLast is the one derived value that is not a position on that list. It says whether next() from the current step finishes the wizard, so it is true on a branch with no on.next, even with steps of order still drawn after it, and on a step whose on.next is '@end', wherever it sits. It is false on a last step whose on.next leads back into the flow. Inside a repeat group it is false on the last step of every item but the last, whose next() goes on to the next item, and true there only when the group is the last step of the wizard.

when and on.next answer different questions, and a step that sets both is usually a mistake: when decides whether the step exists at all, on.next decides where it goes once it does. On the same step they do not interact - the step’s when still hides it, and never chooses where it leads - so validateFlow reports the pair, in case a condition meant for the transition was written on the step.

The constant END marks the exit. go(END) finishes the flow, and on: { next: END } sends a step straight to it.

Two methods write, and they differ in what they touch:

// `wizard` is the engine `createWizard` returns, or `useWizard()` in a component.
wizard.set('details.email', 'ada@example.com'); // replaces the value at that path
wizard.patch({ plan: 'pro' }); // merges shallowly into the root of `data`

set takes a dotted path, replaces what is there and creates the objects along the way. patch takes an object and merges it one level into data, which means a key it carries replaces that key whole rather than being merged into what was there. Reach for patch when a server hands you several top-level values at once, and for set everywhere else.

Both go through the same commit, so a plugin sees each write exactly once and a selector recomputes once.

defineFlow on its own already buys something: the object is inferred const, so the step ids are a union rather than string, and go('reveiw') stops compiling. What it cannot know is what each step writes into data - a definition says nothing about the shape of an answer.

step<T>() and group<T>() say it. Both return their argument untouched, exactly as defineFlow does, and carry T on a phantom property the compiler reads and the runtime never sees:

import { createWizard, defineFlow, step } from '@wizzard-packages/core';
const booking = defineFlow({
id: 'booking',
order: ['who', 'bags'],
steps: {
who: step<{ name: string }>({ label: 'Who is travelling' }),
bags: step<{ count: number }>(),
},
});
const wizard = createWizard({ flow: booking });
wizard.get('who'); // { name: string } | undefined
wizard.set('who', { name: 'Ada' }); // a string here is an error
wizard.set('who.name', 'Grace'); // a nested path is `unknown`: no shape is claimed for it

group<T>() is the same helper for a group step: it declares the shape at that step’s key and returns the definition untouched. Running a flow that has one needs the traversal from core/groups; typing one does not.

The slice belongs to the step’s own key and nothing below it. A path one level down is unknown rather than checked, because a dotted path is a runtime string and narrowing it would mean inferring a type for every path a flow could produce.

Two type helpers expose the same information without a wizard: DataOf<typeof booking> is the data shape, one key per step, and StepIdOf<typeof booking> is the union of ids that go takes.

The reason the type is declared by hand rather than inferred from the literal is speed. Inference stays one level deep, so a forty-step flow type-checks as fast as a three-step one; a version that read the shape out of the definition made tsserver stall on flows this repository ships. The cost is that step<T>() cannot check T against a slice override - a step that redirects its data elsewhere at runtime still reports T at its own key.

None of this is required, and none of it survives serialization. A flow that arrives as JSON is a plain FlowDefinition: go takes string, get returns unknown, and everything runs the same. A typed wizard is also assignable to a plain Wizard, which is what lets a binding store one without knowing the flow it came from.

A flow that arrives from a backend has not been type-checked by anything. validateFlow reads it and returns what is wrong; see Validation.