Skip to content

Render field errors

Validation messages are keyed by field, so each one can be drawn next to the input it is about rather than collected into a list at the top of the form.

Press Next with both fields empty.

flow.ts
import { defineFlow, step } from '@wizzard-packages/core';
/**
* One step with two fields, so there are two messages to place rather than one
* sentence to print. That is the whole difficulty this page is about.
*/
export const checkout = defineFlow({
id: 'render-field-errors',
order: ['details', 'done'],
steps: {
details: step<{ email: string; card: string }>({
label: 'Your details',
validate: { $ref: 'details' },
}),
done: step({ label: 'Done' }),
},
});

One message per field, keyed by the field name. The keys are what make the placement possible.

registry.ts
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/**
* A validator answers with one message per field, keyed by the field's name, or
* `null` when there is nothing to say. The keys are what let the rendering put
* each message beside the input it belongs to.
*/
type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = {
details: (_args, scope: Scope): Fields => {
const { email, card } =
(scope.data['details'] as { email?: string; card?: string } | undefined) ?? {};
const errors: Record<string, string> = {};
if (typeof email !== 'string' || email.trim() === '') {
errors['email'] = 'Enter your email address.';
} else if (!EMAIL.test(email)) {
errors['email'] = 'That does not look like an email address.';
}
const digits = typeof card === 'string' ? card.replace(/\s/g, '') : '';
if (!/^\d{16}$/.test(digits)) errors['card'] = 'A card number is sixteen digits.';
return Object.keys(errors).length === 0 ? null : errors;
},
};

The field and its message are one component, so the two cannot drift apart.

App.tsx
import {
WizardProvider,
useErrors,
useField,
useNavigation,
useStep,
useWizard,
} from '@wizzard-packages/react';
import { useId } from 'react';
import { checkout } from './flow';
import { registry } from './registry';
export function App() {
return (
<WizardProvider flow={checkout} registry={registry}>
<Wizard />
</WizardProvider>
);
}
/**
* One field and its message, kept together so the two cannot drift apart: the
* input points at the message with `aria-describedby`, and says it is wrong
* with `aria-invalid`, which is what a screen reader reads out.
*/
function Field(props: {
label: string;
value: string | undefined;
error: string | undefined;
disabled: boolean;
onChange: (value: string) => void;
}) {
// Generated, not written. Both renderings of this example are in the page at
// once - the tab that is not showing is hidden, not removed - so a fixed `id`
// would be in the document twice, and `for` and `aria-describedby` would both
// resolve to whichever came first rather than to the field beside them.
const id = useId();
const describedBy = props.error === undefined ? undefined : `${id}-error`;
return (
<p>
<label htmlFor={id}>{props.label}</label>
<input
id={id}
value={props.value ?? ''}
onChange={(e) => props.onChange(e.target.value)}
disabled={props.disabled}
aria-invalid={props.error !== undefined}
aria-describedby={describedBy}
/>
{props.error !== undefined && (
<span id={describedBy} role="alert">
{props.error}
</span>
)}
</p>
);
}
export function Wizard() {
const wizard = useWizard();
const { current } = useStep();
const { next, isBusy } = useNavigation();
const [email, setEmail] = useField<string>('details.email');
const [card, setCard] = useField<string>('details.card');
const errors = useErrors();
const step = current ?? 'details';
const starting = current === null;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void next();
}}
>
{step === 'details' && (
<>
<Field
label="Your email"
value={email}
error={errors['email']}
disabled={starting}
onChange={setEmail}
/>
<Field
label="Card number"
value={card}
error={errors['card']}
disabled={starting}
onChange={setCard}
/>
{/* A message the engine could not have produced: the card was well
formed and the payment service still said no. It goes in the same
place, so the rendering needs no second path for it. */}
<button
type="button"
onClick={() => wizard.setErrors('details', { card: 'That card was declined.' })}
disabled={starting}
>
Pretend the server refused
</button>
</>
)}
{step === 'done' && <p>Both fields were fine.</p>}
<button type="submit" disabled={starting || isBusy || step === 'done'}>
Next
</button>
</form>
);
}

useErrors(), a named import from @wizzard-packages/react or @wizzard-packages/vue, returns the map for the current step, or for a step you name. It is a plain object keyed by field:

const errors = useErrors();
errors['email']; // string | undefined

On Vue it is a ComputedRef of the same object, which the template unwraps for you.

Three attributes do the work, and the example above carries all three:

  • aria-invalid on the input, so the field announces itself as wrong.
  • aria-describedby pointing at the message’s id, so the message is read with the field rather than stranded somewhere after it.
  • role="alert" on the message, so it is announced when it appears.

A message that is only a red border says nothing to anyone who cannot see the border.

A card can be perfectly well formed and still be declined. setErrors puts that message in the same place as the validator’s, so the rendering needs no second path for it:

wizard.setErrors('details', { card: 'That card was declined.' });
wizard.setErrors('details', null); // clear

The next successful validation of that step replaces what is there, which is why the message disappears once the field is corrected.

Validation covers when validators run and how to run one by hand. Block Next until valid is the other half of this page: the refusal that produces these messages in the first place.