ehsan.blog
~/blog/how-to-build-an-accessible-form-with-react-hook-form-and-zod — zsh
cat how-to-build-an-accessible-form-with-react-hook-form-and-zod.md

How to build an accessible form with react-hook-form and Zod

·4 min read

A form that only looks correct isn’t done. If a screen-reader user can’t tell which field failed, or can’t hear that their message actually sent, the form is broken for them. This post walks through the contact form on this site and shows exactly which attributes make it accessible — none of them are complicated.

The setup: react-hook-form + Zod

react-hook-form manages form state (values, errors, submission) with very few re-renders. I pair it with Zod for validation through the zodResolver adapter — and that same schema is reused on the server, as covered in sharing one Zod schema between client and server:

tsx
// src/components/portfolio/ContactForm.tsx
const {
  register,
  handleSubmit,
  reset,
  formState: { errors },
} = useForm<ContactInput>({
  resolver: zodResolver(contactSchema),
  mode: "onBlur",
})
  • register("name") connects an input to the form; you spread it onto the element with {...register("name")}.
  • handleSubmit(onSubmit) runs validation first and only calls onSubmit if the data is valid.
  • errors is an object keyed by field name; errors.name exists only when the name field failed.
  • mode: "onBlur" validates a field when the user leaves it, so errors appear as they move through the form rather than only on submit.

A status state machine

Instead of juggling loose booleans like isLoading and isDone, I track one value with four possible states:

tsx
type Status = "idle" | "submitting" | "success" | "error"

const [status, setStatus] = useState<Status>("idle")
const [serverError, setServerError] = useState<string | null>(null)

Only one state can be true at a time, so the UI can’t get into an impossible combination like “loading and success at once.” The submit handler moves through the states:

tsx
async function onSubmit(values: ContactInput) {
  setStatus("submitting")
  setServerError(null)
  try {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    })
    const data = await res.json().catch(() => ({}))
    if (!res.ok || !data.ok) throw new Error(data.error || "Failed to send message.")
    setStatus("success")
    reset()
  } catch (err) {
    setStatus("error")
    setServerError(err instanceof Error ? err.message : "Failed to send message.")
  }
}

Note reset() on success — it clears the fields so the form is ready for the next message.

Making a single field accessible

Here’s the name field. Accessibility lives in three attributes:

tsx
<label htmlFor="cf-name">name</label>
<input
  id="cf-name"
  type="text"
  autoComplete="name"
  aria-invalid={!!errors.name}
  aria-describedby={errors.name ? "cf-name-err" : undefined}
  {...register("name")}
/>
{errors.name && (
  <p id="cf-name-err" role="alert">
    {errors.name.message}
  </p>
)}

What each piece does:

  • htmlFor / id link the label to the input. Clicking the label focuses the field, and screen readers announce the label when the field is focused.
  • aria-invalid is true only when this field has an error, so assistive tech announces the field as invalid. !!errors.name turns the error object (or undefined) into a boolean.
  • aria-describedby points the input at the id of its error message, so the error text is read out as part of the field’s description. I only set it when there’s actually an error — otherwise it’s undefined (unset).
  • role="alert" on the error paragraph makes screen readers announce the message the moment it appears.

The same pattern repeats for the email and message fields — only the ids change.

Announcing the submit outcome

Field errors are covered, but what about “message sent” or “the server failed”? Those appear after submit, so I put them in a live region:

tsx
<div aria-live="polite">
  {status === "error" && serverError && (
    <p role="alert">{serverError}</p>
  )}
</div>

aria-live="polite" tells the screen reader to announce whatever appears inside this container, without interrupting the user mid-sentence. The success state uses role="status" on its container for the same reason. Sighted users get a green checkmark and a message; screen-reader users hear it too.

What to remember

  • Let zodResolver connect react-hook-form to your Zod schema; mode: "onBlur" gives friendly, early validation.
  • Model submission as one status value, not several loose booleans.
  • Each field needs a linked label, aria-invalid, and aria-describedby pointing at its error, plus role="alert" on the error text.
  • Wrap after-submit feedback in an aria-live="polite" region so it’s announced.
  • Call reset() after a successful submit to clear the form.

Related reading: the same accessibility mindset applies to page navigation — see adding an accessible skip link and ARIA-correct navigation. On the backend side, the /api/contact endpoint this form posts to is built in sending email from a serverless function with Resend, and both layers are covered in unit-testing a form and serverless handler with Vitest.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.