// guide

Accessible Forms in React: Labels, Errors, and useId

Forms are where React's control over the DOM helps and hurts in equal measure. This guide covers associating labels with useId, keeping controlled inputs accessible, announcing validation errors with aria-live and aria-describedby, grouping related controls, and giving clear submission feedback.

intermediate react

// 01 · labels and useid

Labels and useId

Every input needs a programmatically associated label — a real <label> tied to the input by matching htmlFor and id. In React the trap is the id: hard-code it and the moment the component renders twice on a page you have duplicate ids, which silently break the association. The useId hook exists to solve exactly this.

A reusable, correctly labelled field
import { useId } from 'react';

function TextField({ label, ...props }) {
  const id = useId(); // unique & stable, even across many instances

  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </div>
  );
}

useId generates a unique, render-stable identifier that also matches between server and client rendering, so it will not cause hydration mismatches. Use it for every id you need to wire up an accessibility relationship — label to input, input to error, group to description. Never invent ids from array indexes or props that might repeat. For the fundamentals of labelling, see the form labeling guide.

// 02 · controlled inputs

Controlled Inputs

Controlled inputs — where React state is the source of truth and onChange updates it — are perfectly accessible, as long as you do not break the basics the browser gives you for free. Keep the native input type, keep a real label, and add the right autocomplete so browsers and password managers can fill it.

An accessible controlled input
function EmailField({ value, onChange }) {
  const id = useId();
  return (
    <div className="field">
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        type="email"          // native type: correct keyboard & validation
        value={value}
        onChange={(e) => onChange(e.target.value)}
        autoComplete="email"  // let the browser autofill
      />
    </div>
  );
}
Do not fight the input in onChange Reformatting or filtering keystrokes inside onChange can move the caret, drop characters, or defeat autofill — and never block paste, especially on passwords and one-time codes (see the accessible authentication guide). Let people type and paste normally; validate on blur or submit, not by rewriting their input mid-keystroke.

// 03 · validation errors and announcements

Validation Errors and Announcements

An error message that is only visible is invisible to a screen reader. Two attributes tie an error to its field so assistive tech reports it: aria-describedby points the input at the error's id, and aria-invalid marks the field as in error.

A field wired to its error message
function EmailField({ value, onChange, error }) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div className="field">
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        type="email"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        autoComplete="email"
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <p id={errorId} className="field-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

How it works: aria-describedby makes a screen reader read the error text when the field gains focus, and aria-invalid announces the field as invalid. Adding role="alert" to the error element means it is also announced the moment it appears, so a user does not have to re-focus the field to hear it. Set aria-invalid and aria-describedby to undefined (not false/empty) when there is no error, so the attributes are omitted entirely rather than pointing at nothing.

// 04 · focus and summaries on submit

Focus and Summaries on Submit

Per-field errors handle the field-by-field case. When a whole form fails validation on submit, do not leave the user to hunt for the problems — take them straight there. Two proven patterns, often combined:

  • Move focus to the first invalid field so the next thing the user does is fix it.
  • Render an error summary at the top of the form listing every error as a link to its field, and move focus to that summary.

Both rely on the ref-and-effect focus pattern — capture a ref, then call .focus() after the failed submit. The full mechanics (including a reusable focusable error summary) are in the focus management in React guide. The summary should be a focusable container (tabIndex={-1}) with role="alert", so it is both announced and navigable.

Validate at the right moment Validating on every keystroke is noisy and can announce errors while the user is still typing. Prefer validating on blur (when they leave a field) and on submit, and only show an error once the user has finished with a field — a gentler experience for everyone, and far less chatter for screen reader users.

// 05 · grouping related controls

Grouping Related Controls

Radio buttons and related checkboxes need a group label, not just individual labels — otherwise a screen reader user hears "Standard, radio button" with no idea what question it answers. Wrap the group in a <fieldset> with a <legend>.

A labelled radio group
function ShippingChoice({ value, onChange }) {
  return (
    <fieldset>
      <legend>Shipping speed</legend>
      {['standard', 'express'].map((option) => {
        const id = `ship-${option}`;
        return (
          <div key={option}>
            <input
              type="radio"
              id={id}
              name="shipping"   // shared name groups the radios
              value={option}
              checked={value === option}
              onChange={(e) => onChange(e.target.value)}
            />
            <label htmlFor={id}>{option}</label>
          </div>
        );
      })}
    </fieldset>
  );
}

The <legend> becomes the group's accessible name, announced alongside each option, and the shared name is what makes the radios mutually exclusive. Associating a <label> with each option also extends the clickable target to the text — an accessibility and a target-size win at once.

// 06 · common mistakes

Common Mistakes

  • Hard-coded ids. Duplicate when a field component renders more than once, breaking label and error associations. Use useId.
  • Placeholder as label. A placeholder is not a label — it vanishes on input and is poorly announced. Use a real <label>.
  • Errors not tied to fields. Visible-only error text with no aria-describedby / aria-invalid. Wire them up.
  • No announcement on validation. Errors that appear silently. Add role="alert" or move focus to a summary.
  • Radio/checkbox groups with no <fieldset>/<legend>. Options with no group context.
  • Div-and-onClick submit buttons. A form submits with a real <button type="submit">, which also enables Enter-to-submit.
Keep going This guide is part of the React accessibility hub. Pair it with focus management in React for the submit-and-focus mechanics, build on the accessible forms pattern, and use the React checklist before shipping.

Frequently asked questions

How do I associate a label with an input in React?

Use htmlFor (JSX's version of the for attribute) pointing at the input's id, and generate that id with the useId hook so it is unique and stable across renders: const id = useId(); then <label htmlFor={id}> and <input id={id}>. Avoid hard-coded ids, which collide when a component is used more than once on a page — the classic React labelling bug.

What is useId and why does it matter for forms?

useId is a React hook that produces a unique, stable identifier that matches between server and client rendering. It exists precisely for accessibility attributes: connecting a label to its input, an input to its error message via aria-describedby, or a group to its description. Because a component can render many times on one page, generating ids with useId instead of hard-coding them prevents duplicate-id bugs that break those associations.

How do I announce form validation errors to screen readers in React?

Do two things. Associate each error with its field using aria-describedby pointing at the error element's id, and set aria-invalid on the field when it is in error. Then render the error text inside a container that is announced — a role="alert" or aria-live="polite" region — so it is read out when it appears. Both matter: the description ties the error to the field, and the live region makes it heard.

Should I move focus to the first error on submit?

Yes, for anything beyond a trivial form. On a failed submit, move focus to the first invalid field (or to an error summary at the top of the form) so keyboard and screen reader users are taken straight to the problem instead of hunting for it. In React, do this with a ref and a focus call in your submit handler. See the focus management in React guide for the mechanics.

Are controlled inputs a problem for accessibility?

No — controlled inputs (value driven by state, updated via onChange) are accessible as long as you keep the fundamentals: a real <label>, a native input type, and error associations. The one thing to watch is not to hijack normal typing or paste behavior in your onChange handler, and never to block paste on fields — especially passwords and one-time codes, as covered in the accessible authentication guide.