@k8ordo/form

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.

tsx
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.

ts
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.tsx
const 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.

Warning

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.

tsx
<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.ts
export 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.

Playground

See how errors appear

A sign-up form with a handle and a password. Every message comes from the page’s schema.

Try it

  1. Type ab as the handle and leave the field. The error asks for at least 3 characters.
  2. Make the confirmation different from the password. The error says they do not match.
  3. Leave a field empty and press “Sign up”. Focus moves to the first field that failed.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2