Skip to content

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.

flow.ts
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.

registry.ts
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;
},
};
App.tsx
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>
);
}

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.

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.

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.