DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building Accessible, Composable Forms in React

Building Accessible, Composable Forms in React

Building Accessible, Composable Forms in React

Forms are where frontend quality becomes visible: they expose your component design, your validation strategy, and whether your UI works for keyboard and screen-reader users. A strong approach is to build forms as small, reusable pieces with accessible labels, inline feedback, and predictable state flow.

What This Guide Covers

This tutorial focuses on a practical form architecture for React apps: semantic markup first, reusable field components second, and validation/error handling that scales without turning into prop soup. Accessibility matters here because proper labels, focus handling, contrast, and ARIA support make forms usable for everyone, not just mouse users.

You’ll build:

  • A reusable TextField component.
  • A form state pattern using useReducer.
  • Inline validation and submit-state handling.
  • Accessible error messaging with aria-describedby and role="alert".
  • A clean pattern for async submission.

Start With Semantic HTML

The best accessible form starts with native elements, because browser semantics already provide much of the behavior you need. Use <label> for every input, keep IDs stable, and avoid replacing native controls with generic elements unless you truly need custom behavior.

type TextFieldProps = {
  id: string
  label: string
  value: string
  error?: string
  onChange: (value: string) => void
  type?: string
  autoComplete?: string
}

export function TextField({
  id,
  label,
  value,
  error,
  onChange,
  type = "text",
  autoComplete,
}: TextFieldProps) {
  const describedBy = error ? `${id}-error` : undefined

  return (
    <div className="field">
      <label htmlFor={id} className="field__label">
        {label}
      </label>

      <input
        id={id}
        type={type}
        className={`field__input ${error ? "field__inputerror" : ""}`}
        value={value}
        autoComplete={autoComplete}
        aria-invalid={!!error}
        aria-describedby={describedBy}
        onChange={(e) => onChange(e.target.value)}
      />

      {error ? (
        <p id={`${id}-error`} className="field__error" role="alert">
          {error}
        </p>
      ) : null}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This pattern keeps the component simple while still supporting screen readers and inline validation. The key detail is that the error text is linked to the input through aria-describedby, and the message is announced with role="alert" when it appears.

Manage Form State Cleanly

For more than a couple of fields, useReducer is often clearer than scattered useState calls. It lets you centralize updates, validation, and submit-state transitions in one place.

import { useReducer } from "react"

type FormState = {
  values: {
    name: string
    email: string
    message: string
  }
  errors: Partial<Record<keyof FormState["values"], string>>
  isSubmitting: boolean
  submitSuccess: boolean
}

type Action =
  | { type: "change"; field: keyof FormState["values"]; value: string }
  | { type: "validate" }
  | { type: "submit-start" }
  | { type: "submit-success" }
  | { type: "submit-failure" }

const initialState: FormState = {
  values: { name: "", email: "", message: "" },
  errors: {},
  isSubmitting: false,
  submitSuccess: false,
}

function validate(values: FormState["values"]) {
  const errors: FormState["errors"] = {}

  if (!values.name.trim()) errors.name = "Name is required."
  if (!values.email.includes("@")) errors.email = "Enter a valid email."
  if (values.message.trim().length < 10) {
    errors.message = "Message must be at least 10 characters."
  }

  return errors
}

function reducer(state: FormState, action: Action): FormState {
  switch (action.type) {
    case "change": {
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        submitSuccess: false,
      }
    }
    case "validate":
      return { ...state, errors: validate(state.values) }
    case "submit-start":
      return { ...state, isSubmitting: true, submitSuccess: false }
    case "submit-success":
      return { ...state, isSubmitting: false, submitSuccess: true, errors: {} }
    case "submit-failure":
      return { ...state, isSubmitting: false }
    default:
      return state
  }
}

export function ContactForm() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return null
}
Enter fullscreen mode Exit fullscreen mode

This structure makes validation explicit and keeps the rendering layer focused on UI. It also avoids duplicating derived state, which is a common source of bugs in forms and other interactive components.

Build The Form UI

Now wire the state into the actual form. Notice how the field component stays reusable while the parent handles validation and submission.

export function ContactForm() {
  const [state, dispatch] = useReducer(reducer, initialState)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()

    dispatch({ type: "validate" })
    const nextErrors = validate(state.values)
    if (Object.keys(nextErrors).length > 0) return

    dispatch({ type: "submit-start" })

    try {
      await new Promise((resolve) => setTimeout(resolve, 1000))
      dispatch({ type: "submit-success" })
    } catch {
      dispatch({ type: "submit-failure" })
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <TextField
        id="name"
        label="Name"
        value={state.values.name}
        error={state.errors.name}
        onChange={(value) => dispatch({ type: "change", field: "name", value })}
        autoComplete="name"
      />

      <TextField
        id="email"
        label="Email"
        value={state.values.email}
        error={state.errors.email}
        onChange={(value) => dispatch({ type: "change", field: "email", value })}
        autoComplete="email"
        type="email"
      />

      <div className="field">
        <label htmlFor="message" className="field__label">
          Message
        </label>
        <textarea
          id="message"
          className={`field__input ${state.errors.message ? "field__inputerror" : ""}`}
          value={state.values.message}
          aria-invalid={!!state.errors.message}
          aria-describedby={state.errors.message ? "message-error" : undefined}
          onChange={(e) =>
            dispatch({ type: "change", field: "message", value: e.target.value })
          }
        />
        {state.errors.message ? (
          <p id="message-error" className="field__error" role="alert">
            {state.errors.message}
          </p>
        ) : null}
      </div>

      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? "Sending..." : "Send message"}
      </button>

      {state.submitSuccess ? (
        <p className="form__success" role="status">
          Thanks, your message was sent.
        </p>
      ) : null}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

A few implementation details matter here. noValidate lets you control the experience yourself instead of mixing browser popups with custom messages, and role="status" is a good fit for success feedback because it announces changes without being as disruptive as an alert.

Style For Clarity

Accessibility is not just markup; focus states and contrast need to remain obvious in every state. A clean CSS baseline can make errors and keyboard focus easy to see without relying on color alone.

.field {
  margin-bottom: 1rem;
}

.field__label {
  display: block;
  margin-bottom: 0.35rem;
  font-weight: 600;
}

.field__input {
  width: 100%;
  padding: 0.75rem 0.875rem;
  border: 1px solid #9ca3af;
  border-radius: 0.5rem;
  font: inherit;
}

.field__input:focus {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

.field__inputerror {
  border-color: #dc2626;
}

.field__error {
  margin-top: 0.35rem;
  color: #b91c1c;
}

.form__success {
  margin-top: 1rem;
  color: #166534;
}
Enter fullscreen mode Exit fullscreen mode

Keep the focus ring visible, because removing it harms keyboard navigation. Also make error text strong enough to read clearly against the background, since contrast problems can make a form effectively broken even when the logic is correct.

Add Better Validation

Basic required checks are fine, but real-world forms benefit from validation that happens at the right time. A common pattern is:

  • Validate on submit for the first pass.
  • Validate on blur for individual fields after the user starts interacting.
  • Revalidate on change only for fields that already have errors.

This avoids shouting at the user too early while still keeping feedback fast. That balance is a recurring theme in usable form design and is especially important in long or sensitive forms.

type Touched = Partial<Record<"name" | "email" | "message", boolean>>

type BetterState = FormState & { touched: Touched }

function betterReducer(state: BetterState, action: any): BetterState {
  switch (action.type) {
    case "blur":
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
        errors: validate(state.values),
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

You can use touched to decide when to show an error. For example, a field can be invalid internally but stay visually quiet until the user leaves it or submits the form.

Make Submission Resilient

Async submissions should have three states: idle, loading, and error or success. Without that structure, users can double-submit, lose context, or wonder whether the app accepted their action.

A practical approach is to disable the submit button while sending, show a visible loading label, and surface server errors near the form rather than in a hidden toast. For example, if your API rejects a payload, map the server response into a form-level error like “Please try again in a moment.”

const [serverError, setServerError] = useState<string | null>(null)

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault()
  setServerError(null)

  const nextErrors = validate(state.values)
  if (Object.keys(nextErrors).length > 0) return

  dispatch({ type: "submit-start" })

  try {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(state.values),
    })

    if (!res.ok) throw new Error("Request failed")
    dispatch({ type: "submit-success" })
  } catch {
    setServerError("Something went wrong. Please try again.")
    dispatch({ type: "submit-failure" })
  }
}
Enter fullscreen mode Exit fullscreen mode

If you keep the error close to the form and preserve the user’s typed values, recovery becomes easy and frustration drops. That matters more than making the happy path look polished.

A Practical Checklist

Use this checklist when building a production form:

  • Every input has a matching <label>.
  • Errors are linked with aria-describedby.
  • Focus states are visible and keyboard-friendly.
  • The form does not wipe user input on failure.
  • Validation messages are specific, short, and actionable.
  • Native HTML controls are used unless custom behavior is truly needed.

Common Mistakes

The most common mistake is making a form look custom while accidentally breaking native behavior. Another common issue is validating every keystroke before the user has finished typing, which makes forms feel hostile instead of helpful.

Also avoid hiding all feedback in toast notifications, because screen-reader and keyboard users often benefit more from inline messages tied directly to the relevant field. Finally, do not treat accessibility as a final polish step; it should shape the component API from the beginning.

Next Steps

Once this pattern is in place, you can extend it with field arrays, wizard-style multi-step flows, or schema-based validation libraries like Zod or Yup. The key is to keep the same structure: semantic HTML, reusable components, and state that clearly separates values, errors, and submission status.

A solid form architecture pays off quickly because it reduces bugs, improves usability, and keeps future features from becoming tangled. That is what makes frontend engineering feel maintainable instead of fragile.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)