React 19 changed how forms are built. The useActionState hook (renamed
from useFormState) plus Server Actions remove most of the boilerplate
around controlled inputs and submission state — but they also
introduce new accessibility pitfalls around focus management and error
announcement when the server returns validation failures.
This guide covers the 2026 baseline patterns. Versions older than 19 should consult the React 18 guide.
Native HTML first
React makes it tempting to wrap every input in a custom component. Don’t,
unless you have a good reason. The browser’s native <input>, <select>,
and <textarea> come with the right name, role, value, and validation
behaviour for free.
export function ContactForm() {
return (
<form action={submitContact}>
<p>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</p>
<p>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
aria-describedby="email-help"
/>
<span id="email-help" className="help">
We will reply within two business days.
</span>
</p>
<button type="submit">Send</button>
</form>
);
}
The htmlFor to id link is required for label association —
implicit labelling (wrapping the input in <label>) works but is
fragile when the input is rendered through a child component. Be
explicit.
Server actions and useActionState
Server Actions in React 19 let you submit a form to a server function
without writing an API route by hand. The useActionState hook returns
the latest server response; pair it with useFormStatus for pending state.
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export function ContactForm() {
const [state, dispatch, isPending] = useActionState(submitContact, {
ok: false,
fieldErrors: {},
message: "",
});
return (
<form action={dispatch}>
<p>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
aria-invalid={!!state.fieldErrors.email}
aria-errormessage="email-error"
/>
{state.fieldErrors.email && (
<span id="email-error" className="error">
{state.fieldErrors.email}
</span>
)}
</p>
<button type="submit" disabled={isPending}>
{isPending ? "Sending…" : "Send"}
</button>
<p role="status" aria-live="polite">
{state.ok ? "Thanks. We'll be in touch." : ""}
</p>
</form>
);
}
Three accessibility moves here:
aria-invalidflips when the server returns a field error. Screen readers announce “invalid” when the user re-enters the field.aria-errormessagepoints to the visible error span. NVDA and VoiceOver read both the field name and the error on focus.- The success notice is wrapped in a
role="status"live region so a screen-reader user hears confirmation without focus moving.
Focus on error — the missing piece
The pattern above renders errors but does not move focus. A keyboard user submitting an invalid form must Tab back to find the first error. A focus-on-error effect closes the gap:
import { useEffect, useRef } from "react";
export function ContactForm() {
const [state, dispatch, isPending] = useActionState(submitContact, /* ... */);
const firstErrorRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (Object.keys(state.fieldErrors).length > 0) {
firstErrorRef.current?.focus();
}
}, [state]);
// attach ref={firstErrorRef} to whichever field is the first invalid one
// (compute from a defined field order, not Object.keys() iteration order)
}
Pick the first invalid field by visual order, not by hash order. A
deterministic field-order array (["name", "email", "message"]) makes
this stable.
Inline validation — politely
If you also do client-side validation (cheap pre-check before server
round-trip), announce the error via aria-live="polite". Avoid
assertive — it interrupts the user mid-typing.
const [emailError, setEmailError] = useState("");
function onBlurEmail(e: React.FocusEvent<HTMLInputElement>) {
const v = e.target.value;
setEmailError(v && !v.includes("@") ? "Email needs an @ sign." : "");
}
return (
<>
<input
type="email"
onBlur={onBlurEmail}
aria-invalid={!!emailError}
aria-describedby="email-live"
/>
<span id="email-live" aria-live="polite" className="visually-hidden">
{emailError}
</span>
</>
);
Announce on blur, not on every keystroke. On-keystroke validation floods the screen reader.
Required fields
required on the input is enough; do not duplicate with aria-required
unless the input is not a native form control. Visually marking
required fields with an asterisk requires a legend (”* required field”
in the form) so the asterisk has meaning to screen readers.
For optional fields, mark them “(optional)” in the visible label rather than relying on the absence of an asterisk.
Pending and disabled state
useFormStatus from react-dom gives a child component access to the
parent form’s submission state without prop drilling. Pair it with a
visible pending message and aria-busy on the form:
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? "Sending…" : "Send"}
</button>
);
}
Do not disable the entire form on submit — users who miss-click become unable to correct it. Disable only the submit button.
Cross-links
- Reference: React 19 useActionState docs; useFormStatus docs.
- WCAG: How to fix WCAG 4.1.2 name role value; How to fix WCAG 1.3.1 info and relationships.
- Adjacent framework: Vue 3.5 dialogs.
- Testing: How to wire axe-core into CI for automated checks; How to test with NVDA for manual verification.
The simplest test of a React 19 form: unplug your mouse, submit it empty, and verify NVDA announces every error and lands focus on the first invalid field. If that flow works, the form is ready to ship.