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.
The definition
Section titled “The definition”| 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.
What happens to a step’s data
Section titled “What happens to a step’s data”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.
The two fields called validate
Section titled “The two fields called validate”They sit at different levels and mean different things, and confusing them is the most common misreading of this model.
FlowDefinition.validateis scheduling:{ on: 'change' | 'blur' | 'next' | 'manual', debounceMs }. It decides when validation runs.AtomStep.validateis 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.
Where a step leads
Section titled “Where a step leads”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.
Writing into data
Section titled “Writing into data”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 pathwizard.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.
Typing what a step writes
Section titled “Typing what a step writes”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 } | undefinedwizard.set('who', { name: 'Ada' }); // a string here is an errorwizard.set('who.name', 'Grace'); // a nested path is `unknown`: no shape is claimed for itgroup<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.
Checking a definition before it runs
Section titled “Checking a definition before it runs”A flow that arrives from a backend has not been type-checked by anything. validateFlow
reads it and returns what is wrong; see Validation.