Block Next until valid
A step that must be filled in correctly before the flow moves on declares a validator, and the engine refuses the move until it passes. You do not write that check into the button.
Press Next below with the field empty.
`validate` names a resolver rather than holding a function, because the flow has to stay JSON.
import { defineFlow, step } from '@wizzard-packages/core';
/** * The smallest flow with a guard on it: one step that will not be left until * its validator is happy, and somewhere to go once it is. * * `validate` names a resolver rather than holding a function, because the flow * has to stay JSON. `registry.ts` is where that name is answered. */export const signup = defineFlow({ id: 'block-next', order: ['details', 'done'], steps: { details: step<{ email: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});The function that name resolves to. It answers with a message per field, or `null` when the step is fit to leave.
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * What the flow could not serialize. A validator is an ordinary function of the * data: it answers with the field messages it found, or `null` when the step is * fit to leave. * * Returning messages keyed by field is what lets the rendering put each one * beside the input it belongs to, rather than showing one sentence for the * whole form. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email } = (scope.data['details'] as { email?: string } | undefined) ?? {}; if (typeof email !== 'string' || email.trim() === '') { return { email: 'Enter your email address.' }; } if (!EMAIL.test(email)) return { email: 'That does not look like an email address.' }; return null; },};import { WizardProvider, useErrors, useField, useNavigation, useStep,} from '@wizzard-packages/react';import { useId } from 'react';
import { signup } from './flow';import { registry } from './registry';
export function App() { return ( <WizardProvider flow={signup} registry={registry}> <Wizard /> </WizardProvider> );}
export function Wizard() { const { current } = useStep(); const { next, isBusy } = useNavigation(); const [email, setEmail] = useField<string>('details.email'); const errors = useErrors();
// Nothing is current until the engine starts, which happens in the browser. const step = current ?? 'details'; const starting = current === null;
// A generated id rather than a written one. On the page this example appears // on, the React and Vue renderings are both in the document - the tab that is // not showing is hidden, not removed - so a fixed `id` would be there twice // and every `for` pointing at it would find the wrong field. const emailId = useId();
return ( <form onSubmit={(e) => { e.preventDefault(); // The refusal is the return value, not an exception: `next()` resolves // to `{ ok: false, reason: 'invalid', errors }` and the flow stays put. void next(); }} > {step === 'details' && ( <> <label htmlFor={emailId}>Your email</label> <input id={emailId} value={email ?? ''} onChange={(e) => setEmail(e.target.value)} disabled={starting} aria-invalid={errors['email'] !== undefined} aria-describedby={errors['email'] === undefined ? undefined : `${emailId}-error`} /> {errors['email'] !== undefined && ( <p id={`${emailId}-error`} role="alert"> {errors['email']} </p> )} </> )} {step === 'done' && <p>That address will do.</p>}
<button type="submit" disabled={starting || isBusy || step === 'done'}> Next </button> </form> );}import { defineFlow, step } from '@wizzard-packages/core';
/** * The smallest flow with a guard on it: one step that will not be left until * its validator is happy, and somewhere to go once it is. * * `validate` names a resolver rather than holding a function, because the flow * has to stay JSON. `registry.ts` is where that name is answered. */export const signup = defineFlow({ id: 'block-next', order: ['details', 'done'], steps: { details: step<{ email: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * What the flow could not serialize. A validator is an ordinary function of the * data: it answers with the field messages it found, or `null` when the step is * fit to leave. * * Returning messages keyed by field is what lets the rendering put each one * beside the input it belongs to, rather than showing one sentence for the * whole form. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email } = (scope.data['details'] as { email?: string } | undefined) ?? {}; if (typeof email !== 'string' || email.trim() === '') { return { email: 'Enter your email address.' }; } if (!EMAIL.test(email)) return { email: 'That does not look like an email address.' }; return null; },};The parent provides the wizard; `inject` reads the parent chain.
<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Wizard from './Wizard.vue';import { signup } from './flow';import { registry } from './registry';
// The wizard is provided here and consumed by the child. Vue's inject reads the// parent chain, so the component that provides cannot also use the composables.provideWizard({ flow: signup, registry });</script>
<template> <Wizard /></template><script setup lang="ts">import { useErrors, useField, useNavigation, useStep } from '@wizzard-packages/vue';import { computed, useId } from 'vue';
const { current } = useStep();const { next, isBusy } = useNavigation();const email = useField<string>('details.email');const errors = useErrors();
// Nothing is current until the engine starts, which happens in the browser.const step = computed(() => current.value ?? 'details');const starting = computed(() => current.value === null);
// A generated id rather than a written one. On the page this example appears on,// the React and Vue renderings are both in the document - the tab that is not// showing is hidden, not removed - so a fixed `id` would be there twice and// every `for` pointing at it would find the wrong field.const emailId = useId();</script>
<template> <!-- The refusal is the return value, not an exception: `next()` resolves to `{ ok: false, reason: 'invalid', errors }` and the flow stays put. --> <form @submit.prevent="next()"> <template v-if="step === 'details'"> <label :for="emailId">Your email</label> <input :id="emailId" v-model="email" :disabled="starting" :aria-invalid="errors['email'] !== undefined" :aria-describedby="errors['email'] === undefined ? undefined : `${emailId}-error`" /> <p v-if="errors['email'] !== undefined" :id="`${emailId}-error`" role="alert"> {{ errors['email'] }} </p> </template> <p v-else-if="step === 'done'">That address will do.</p>
<button type="submit" :disabled="starting || isBusy || step === 'done'">Next</button> </form></template>import { defineFlow, step } from '@wizzard-packages/core';
/** * The smallest flow with a guard on it: one step that will not be left until * its validator is happy, and somewhere to go once it is. * * `validate` names a resolver rather than holding a function, because the flow * has to stay JSON. `registry.ts` is where that name is answered. */export const signup = defineFlow({ id: 'block-next', order: ['details', 'done'], steps: { details: step<{ email: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * What the flow could not serialize. A validator is an ordinary function of the * data: it answers with the field messages it found, or `null` when the step is * fit to leave. * * Returning messages keyed by field is what lets the rendering put each one * beside the input it belongs to, rather than showing one sentence for the * whole form. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email } = (scope.data['details'] as { email?: string } | undefined) ?? {}; if (typeof email !== 'string' || email.trim() === '') { return { email: 'Enter your email address.' }; } if (!EMAIL.test(email)) return { email: 'That does not look like an email address.' }; return null; },};import { createWizard } from '@wizzard-packages/core';
import { signup } from './flow';import { registry } from './registry';
/** * The same guard without a framework. `next()` answers with a value either way, * so the refusal is read, not caught. */const wizard = createWizard({ flow: signup, registry });
await wizard.start();console.log(`step: ${String(wizard.getSnapshot().current)}`);
const refused = await wizard.next();if (!refused.ok) { console.log(`next: ${refused.reason} (${JSON.stringify(refused.errors)})`); console.log(`step: ${String(wizard.getSnapshot().current)}`);}
wizard.set('details.email', 'ada@example.com');const moved = await wizard.next();console.log(`next: ${moved.ok ? 'ok' : moved.reason}`);console.log(`step: ${String(wizard.getSnapshot().current)}`);A test in the repository asserts these exact lines.
step: detailsnext: invalid ({"email":"Enter your email address."})step: detailsnext: okstep: doneWhat happens
Section titled “What happens”next() - the one useNavigation() returns in the component above - does not throw when the
validator refuses. It resolves to a value that says why:
const result = await next();// { ok: false, reason: 'invalid', errors: { email: 'Enter your email address.' } }The flow stays where it was, and the messages are written into the wizard’s state, where
useErrors() reads them. A component that renders a field does not need the result of the call
that produced its message.
Things worth knowing
Section titled “Things worth knowing”The validator is a function of the data, not of a form: it reads scope.data and returns field
messages. That is what makes it testable without a browser, and what lets the headless tab above
drive the same check with no rendering at all.
A validator may be asynchronous. While it is in the air isBusy is true, which is what disables
the button - and if the person presses Back before it answers, the answer is discarded rather
than applied to a step they have left.
Where to read more
Section titled “Where to read more”Validation separates checking a definition from checking a user’s answers and
covers validate(step?), setErrors, and why there is no validateAll(). Navigation has the
full table of refusal reasons. Render field errors is the other half
of this page: where the messages go once you have them.