React 19 form accessibility patterns

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&hellip;" : "Send"}
      </button>
      <p role="status" aria-live="polite">
        {state.ok ? "Thanks. We'll be in touch." : ""}
      </p>
    </form>
  );
}

Three accessibility moves here:

  1. aria-invalid flips when the server returns a field error. Screen readers announce “invalid” when the user re-enters the field.
  2. aria-errormessage points to the visible error span. NVDA and VoiceOver read both the field name and the error on focus.
  3. 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&hellip;" : "Send"}
    </button>
  );
}

Do not disable the entire form on submit — users who miss-click become unable to correct it. Disable only the submit button.

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.