react nextjs accessibility patterns

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 Identification
  • 3.3.3 Error Suggestion
  • 2.4.3 Focus Order
  • 4.1.3 Status Messages
What happens between a failed submit and a user hearing about it A five-step chain. The user activates submit. React Hook Form validates and populates its errors object. The application renders an error summary listing every problem as a link to its field. Focus moves to the summary container, which carries tabindex minus one, so the count is announced immediately. Each field is separately marked invalid and wired to its own message. A note underneath says that skipping the focus move leaves the whole chain silent. Submit pressed errors object populated by RHF Summary rendered one link per problem Focus moves there "3 problems to fix" In parallel: each field marked invalid and wired to its own message aria-invalid="true" and aria-describedby pointing at the field's error paragraph Without the focus move, all of this renders correctly and the user hears nothing at all.

Prerequisites

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 — a useEffect keyed 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-invalid from the presence of an error for that field, and remove it when the error clears.
  • Point aria-describedby at both the hint and the error, error last.
  • Register the field with an id matching the summary link's fragment, so the link actually lands somewhere.
  • Keep messages actionable: 3.3.3 Error Suggestion asks 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".

Routing a server error back into the same summary A flow showing a submit that passes client validation and then fails on the server. The response contains field-level errors and a form-level message. Field errors are pushed into React Hook Form with setError so they render exactly like client errors, and the form-level message becomes the first entry in the same summary. Focus moves to the summary as it would for a client failure, so the user hears one consistent experience regardless of where validation ran. Client validation passes Server responds 422 field + form messages setError() per field, root for the rest renders identically to client errors Same summary, same focus move, same announcement The user cannot tell — and does not need to know — where validation ran. One code path means the server case cannot be the one nobody tested.

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 root error 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.
Choosing between a focused summary and a live region Two mutually exclusive options for announcing a failed submit. Moving focus to the summary announces its content once and puts the user at the top of the list they need to work through, which suits any failure caused by their own submit. A live region announces without moving focus, which suits a late asynchronous failure arriving after the user has moved on. Using both at once announces everything twice. Focus the summary announced once user starts at the list for any submit failure ✓ the default Live region only focus stays put no interruption of work for late async failures ✓ the exception Both together focus announces it the region announces it the user hears it twice ✗ pick one

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.activeElement is 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.