Announcing React Hook Form Submission Errors
React Hook Form gives you a complete errors object and no opinion at all about how it should reach a user who cannot see the red text. Left as-is, a failed submit does nothing perceivable: focus stays on the button, no announcement is made, and the messages appear somewhere below the fold. The fix is small and mechanical — a summary that receives focus, per-field associations, and exactly one announcement — and it works the same whether validation is synchronous, schema-driven, or comes back from the server.
This implements the submit path described in Form Handling with React Hook Form & A11y.
WCAG Success Criteria Addressed:
3.3.1 Error Identification3.3.3 Error Suggestion2.4.3 Focus Order4.1.3 Status Messages
Prerequisites
- React Hook Form 7+ with a resolver if you validate with Zod, Yup or Valibot.
- A
Fieldcomponent that already wires labels andaria-describedby— see associating error messages with aria-describedby.
Render a Summary and Focus It
The summary is what turns a failed submit from silent into obvious. It needs to be in the DOM before focus moves to it, and it needs a heading that states the count.
Implementation Guidelines:
- Render the summary only when there are errors, at the top of the form, before the first field.
- Give the container
tabindex="-1"and focus it after the render that shows it — auseEffectkeyed on the error count does this reliably. - Head it with the count ("3 problems to fix"), because the number is the first thing the user needs.
- Make each entry a link to the field's
id, so activating it moves focus to the input that needs work.
'use client';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
export function CheckoutForm() {
const { register, handleSubmit, formState: { errors, submitCount } } = useForm();
const summaryRef = useRef<HTMLDivElement>(null);
const list = Object.entries(errors);
useEffect(() => {
// A11y rationale: 3.3.1 Error Identification — focusing the summary after a
// failed submit makes the count the next thing the user hears.
if (submitCount > 0 && list.length > 0) summaryRef.current?.focus();
}, [submitCount, list.length]);
return (
<form onSubmit={handleSubmit(onValid)} noValidate>
{list.length > 0 && (
<div ref={summaryRef} tabIndex={-1} className="error-summary">
<h2>{list.length} problem{list.length > 1 ? 's' : ''} to fix</h2>
<ul>
{list.map(([name, error]) => (
<li key={name}>
<a href={`#${name}`}>{String(error?.message)}</a>
</li>
))}
</ul>
</div>
)}
{/* fields */}
</form>
);
}
Note noValidate on the form: without it the browser's own validation bubbles fire first, and those bubbles are not announced consistently, cannot be styled, and disappear on the next interaction.
Wire Each Field As Well
The summary is an index. The field still needs its own message, because that is what the user hears when they arrive there to fix it.
Implementation Guidelines:
- Set
aria-invalidfrom the presence of an error for that field, and remove it when the error clears. - Point
aria-describedbyat both the hint and the error, error last. - Register the field with an
idmatching the summary link's fragment, so the link actually lands somewhere. - Keep messages actionable:
3.3.3 Error Suggestionasks for a suggested fix where one is known — "Enter a date in the future", not "Invalid date".
<Field
label="Email address"
hint="We only use this for receipts."
error={errors.email?.message as string | undefined}
id="email"
{...register('email', {
required: 'Enter your email address.',
pattern: { value: /.+@.+\..+/, message: 'Enter a complete email address.' },
})}
/>
Server Errors Take the Same Path
An error that comes back from the server after a successful client-side validation is the case most often left silent, because the form has already "passed".
Implementation Guidelines:
- Map field-level server errors onto form state with
setError(name, { message }), so they render through the same components. - Put form-level failures ("Payment declined") on the
rooterror and show them as the first summary entry. - Move focus to the summary for server failures exactly as for client ones — the fact that a round trip happened is irrelevant to the user.
- If the response arrives after the user has moved on, do not steal focus; announce politely through the form's status region instead and leave the summary in place.
async function onValid(values: FormValues) {
const response = await submitOrder(values);
if (response.ok) return;
for (const [field, message] of Object.entries(response.fieldErrors)) {
setError(field as keyof FormValues, { message });
}
if (response.formError) setError('root', { message: response.formError });
}
One Announcement, Not Three
The summary being focused is the announcement. Adding a live region on top produces the message twice.
Implementation Guidelines:
- Do not wrap the summary in
role="alert"if you are also moving focus to it — the content is announced by the focus move. - Use a live region only when you deliberately do not move focus, such as an async failure arriving late.
- Never mark individual field messages as alerts; they are announced when the user reaches the field.
- Keep the pending state quiet: announce "Submitting…" only if the request takes longer than about half a second.
How to Verify
- Empty submit. Submit an empty form with a screen reader running. You should hear the problem count once, and land in the summary.
- Link test. Activate a summary link; focus must land on the field, which then announces its label and message.
- Fix and resubmit. Correct one field and resubmit; the summary should re-announce the new, smaller count.
- Server-error test. Force a 422 response. The experience should be indistinguishable from a client-side failure.
- Automated check. Assert with Testing Library that
document.activeElementis the summary after a failed submit, and that each invalid input has an accessible description.
Common Accessibility Mistakes
- No focus move. The summary renders perfectly and is never announced; the user presses submit again.
- Focusing the first invalid field instead of the summary. The user hears one problem and has no idea there are three.
- Both a focused summary and
role="alert". Everything is announced twice. - Leaving browser validation on. Native bubbles pre-empt your summary and are announced inconsistently.
- Server errors rendered in a different component. The one path nobody tested is the one that fails in production.
- Messages without suggestions. "Invalid" tells the user they are wrong and not how to be right.
Conclusion
React Hook Form does the hard part — tracking validity — and leaves the perceivable part to you. Render a summary with the problem count, give it tabindex="-1", focus it after any failed submit including server failures, and wire each field to its own message with aria-invalid and aria-describedby. Then resist adding a second announcement channel. The whole implementation is perhaps forty lines, and it converts the most common form failure mode — silence — into an experience that tells every user exactly what to fix.
One measurement worth taking: count how many submits it takes a keyboard-only user to complete your longest form. If the answer is more than two, the summary is probably not carrying enough information — the user is discovering problems one at a time rather than all at once. A complete summary, with a suggestion in each message, turns the second attempt into the successful one, which is the difference between a form people finish and a form they abandon on a phone in a queue.
Frequently Asked Questions
Should the summary use role="alert"?
Not if you move focus to it, because the focus move already causes it to be announced and the two together produce a duplicate. Use role="alert" only in the variant where focus deliberately stays put, such as an error arriving after the user has moved elsewhere.
What about React Hook Form's shouldFocusError?
It focuses the first invalid field on submit, which is better than nothing and worse than a summary: the user hears one problem rather than the count. Turn it off and manage focus yourself so they get the complete picture first.
How do I handle errors on fields inside a collapsed section? Expand the section before moving focus, otherwise the summary link lands on a hidden input. Expanding as part of the same update is the cleanest approach, and it is worth announcing that a section was opened.
Do I need noValidate on the form?
Yes, when you own validation. Without it the browser's constraint validation runs first, showing bubbles you cannot style and stopping submission before your handler runs — so your carefully built summary never appears.
Should the summary persist after the user starts fixing things? Keep it until the next submit, but update the entries as fields become valid. Removing it on the first keystroke destroys the list the user was working through, which is particularly disruptive if they were navigating it link by link.
Related guides
- Form Handling with React Hook Form & A11y — the parent guide, including validation modes.
- Associating Error Messages with aria-describedby — the per-field wiring this page relies on.
- Accessible Inline Validation Timing & Debouncing — when errors should appear before submit.
- Accessible File Upload Inputs in React — a field type with its own error semantics.