Your React Form Works. But Can a Screen Reader Use It?
Ahda Hardyatmaka - Developer
●●
Most developers building forms in React do the basics right. Labels are connected to inputs. The tab order makes sense. Required fields are marked. That's a solid foundation, but there's a gap that's easy to miss: what happens after the user hits Submit?
For a sighted user, the experience is immediate. They see a red error message, a success banner, or a loading spinner. For someone using a screen reader, though? Silence. The form just... sits there. The error exists on the page, but it was never announced. From the user's perspective, nothing happened.
This article is about closing that gap. We'll use React Hook Form as our foundation, then layer in the accessibility pieces it doesn't handle out of the box.
What React Hook Form gives you (and what it doesn't)
React Hook Form does a lot of the accessibility groundwork automatically. When you wire up a field with register() and pull errors from formState, you get things like error tracking per field, a boolean for whether the form is currently submitting, and validation state you can read from anywhere in the component.
The standard pattern for an accessible field looks like this:
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
The aria-invalid attribute signals to assistive technology that something's wrong with this field. The aria-describedby connects the input to its error message, so a screen reader will read the error when the field is focused.
But here's what React Hook Form doesn't handle:
-
Announcing validation failures to screen readers when Submit is clicked
-
Announcing success or failure after the form sends
-
Communicating loading states ("please wait...")
-
Creating ARIA live regions for status updates
Those four things are entirely on you. Let's fix that.
The key concept: ARIA live regions
Before we look at patterns, it's worth understanding why screen readers miss these announcements in the first place.
Screen readers announce content in two situations: when focus moves to an element, or when content changes inside a designated "live region." A live region is just a DOM element tagged with aria-live. When text inside it changes, the screen reader reads it aloud, no focus movement required. Think of it as a news ticker that the screen reader monitors in the background.
There are two flavors:
<!-- Polite: waits for the screen reader to finish its current sentence -->
<div role="status" aria-live="polite">Form submitted successfully!</div>
<!-- Assertive: interrupts immediately -->
<div role="alert" aria-live="assertive">Error: 2 fields need your attention.</div>
Use polite for success messages and loading states. Use assertive for errors, they need to interrupt, because the user is blocked.
One important quirk: the live region must exist in the DOM before its content changes. If you inject a role="alert" div and its message at the same time, many screen readers (especially on mobile) won't catch it. Add the region on mount, then update its content.
Pattern 1: Global form status announcements
This is the simplest pattern and works well for short forms. The idea is a small, mostly-invisible component that announces overall form state changes — "submitting," "success," or "failed with N errors."
The component itself is simple. The tricky part is the timing: you need to clear the message and re-insert it (with a brief timeout) to ensure the screen reader announces it even when the same message fires twice in a row.
📎 Check the full FormStatus component on GitHub
Here's how you integrate it with React Hook Form's handleSubmit:
const onError = () => {
const errorCount = Object.keys(errors).length;
setStatusMessage(
`Form submission failed. ${errorCount} ${
errorCount === 1 ? 'field has' : 'fields have'
} errors. Please correct them and try again.`
);
setStatusType('error');
};
// handleSubmit accepts a second argument for validation failures
<form onSubmit={handleSubmit(onSubmit, onError)}>
That onError callback is the key insight. Most developers only pass one function to handleSubmit — the happy path. The second argument fires when client-side validation fails, and it's the right place to trigger your screen reader announcement.
Pattern 2: Error summary at the top of the form
For longer forms, a global status announcement isn't quite enough. Errors might be halfway down the page. The user knows something's wrong, but they still have to hunt for it.
The better approach for complex forms: an error summary box that appears at the top, lists every error with a link directly to the problematic field, and — critically — receives keyboard focus when it appears.
useEffect(() => {
if (isVisible && summaryRef.current) {
summaryRef.current.focus(); // Pull the user straight to the error list
}
}, [isVisible]);
The summary uses tabIndex={-1} on its container so it can receive programmatic focus even though it's a div. Each error is a link that, when clicked, moves focus to the relevant input. This pattern is recommended by WCAG 3.3.1 for complex forms.
📎 Check the full ErrorSummary component on GitHub
The visual result looks something like this when it appears:
There are 3 errors with your submission Email: Please enter a valid email address Password: Password must be at least 8 characters Phone: Phone must be 10 digits
Each line is a link that jumps to the corresponding field.
Which pattern should you use? Both patterns solve real problems, but in different situations.
Pattern 1 alone is fine for short forms (3–5 fields), mobile-first designs, or any form where all the fields are visible at once. The auditory feedback does the job.
Pattern 2 alone works well for long forms, government-style interfaces, or anywhere WCAG compliance is a hard requirement and you're less concerned with loading/success announcements.
Both together gives you the best coverage for complex forms: Pattern 1 fires immediately when Submit is clicked ("Form has 3 errors"), then Pattern 2 captures keyboard focus and lets users navigate the error list.
Handling loading states
A spinner tells sighted users the form is processing. For everyone else, that spinner is invisible.
The fix is announcing the loading state through your live region. React Hook Form gives you isSubmitting from formState, use it to drive both the announcement and the button state:
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
disabled prevents double-submissions. aria-busy tells screen readers the button is actively processing. And your status announcement ("Submitting your form, please wait...") keeps the user informed while they wait.
📎 Check the full loading state example on GitHub
Server-side errors
Client-side validation catches the obvious stuff, but some errors can only come from the server, a username that's already taken, an email that's already registered, or a service that's temporarily unavailable.
React Hook Form has setError() for exactly this. You can inject a server error onto a specific field and it behaves just like a regular validation error:
// Map server errors back to their fields
errorData.errors.forEach((error) => {
setError(error.field as keyof FormData, {
type: 'server',
message: error.message,
});
});
For errors that aren't tied to a specific field (rate limiting, network failure, service outage), use root.serverError:
setError('root.serverError', {
type: 'server',
message: 'Unable to reach the server. Please try again.',
});
In both cases, tie the error back to your status announcement component so screen reader users hear about it too.
📎 Check the full server error handling example on GitHub
Putting it all together
The complete working example is a contact form with five fields, all three patterns implemented, server error handling, and loading state feedback.
📎 Check the complete working example on GitHub
It's built to be a practical starting point. Copy it, adjust the schema, swap in your API endpoint, and you have a fully accessible form.
React Hook Form gives you a strong accessibility foundation, but announcing form status to screen reader users (as errors, loading states, success) is still your responsibility, and it's not hard to add once you understand ARIA live regions.
Resources
Contact Us
Ready to explore how accessibility can transform your products? Visit our contact page to learn more about AccessTime consultancy services, or try Access Lens to get started with a fresh perspective on what's possible.
Share:

