Show errors
An error the browser finds and one the server returns both arrive in the same error. This page covers where to show errors, and where focus goes when a submission fails.
On this page
Show an error on its field
field() returns the field’s error in error, and whether it failed in invalid.
const title = form.field('title');
<label htmlFor="title">Title</label>
<input {...title.input} aria-describedby="title-error" id="title" />
{title.error !== undefined && <p id="title-error">{title.error}</p>}An error appears when the person leaves the field or submits, never in the middle of typing, and clears once the value is fixed.
Change the wording
What is shown is zod’s own message. To change it, write it in the schema.
z.object({
title: z.string().min(1, 'Enter a title').max(120, 'Keep it to 120 characters'),
});The browser and the server both take the message from the same schema, so the two sides always say the same thing.
To follow the request’s locale, pass the message as a function and call formFields during the render. Called at module scope, it is fixed to whichever locale ran first.
page.tsxconst talkSchema = z.object({
title: z.string().min(1, { error: () => m.talk.titleMissing() }),
});
export default function NewTalkPage() {
const talkFields = formFields(talkSchema);
return <TalkForm action={createTalk} fields={talkFields} />;
}Move focus to the failure
When a submission fails, focus moves to the first failed field on the page. There is nothing to write.
“First” means document order, not the order of the schema’s keys.
Pitfall
When you build a state by hand, give it a new token. Without one, two identical failures in a row read as the same response, and focus does not move.
Show an error about the whole form
An error that belongs to no field goes to form.formError. One example is a .refine() on the whole schema with no path.
<form {...form.props} action={formAction}>
{form.formError.message !== undefined && (
<p {...form.formError.props}>{form.formError.message}</p>
)}
{/* fields */}
</form>Spread formError.props onto the element that shows it. That lets it take focus, so a screen reader announces it.
Place it above the fields. If fields failed too, focus lands here first.
Return a failure only the server can find
Some failures, such as a title that is already taken, only show up once you look in the database. When you find one after parseForm succeeds, add the error to parsed.state and return it.
actions.tsexport async function createTalk(
_prev: FormState,
formData: FormData,
) {
const parsed = parseForm(talkSchema, formData);
if (!parsed.success) return parsed.state;
if (await titleExists(parsed.data.title)) {
return {
...parsed.state,
errors: { title: 'A talk with this title already exists' },
};
}
await saveTalk(parsed.data);
redirect(href('/talks'));
}parsed.state already holds what was typed and a fresh token, so the values come back and focus moves to that field.
See how errors appear
A sign-up form with a handle and a password. Every message comes from the page’s schema.
Try it
- Type
abas the handle and leave the field. The error asks for at least 3 characters. - Make the confirmation different from the password. The error says they do not match.
- Leave a field empty and press “Sign up”. Focus moves to the first field that failed.