What you'll learn

By the end of this you'll know how to wire up controlled inputs the right way, build validation UX that actually helps users, make your forms accessible without fighting with ARIA, when to reach for a schema validator like Zod, and why you probably don't need a form library yet. You'll leave with patterns that work in both plain React and the Next.js App Router.

These aren't the happy-path examples from the docs. They're the forms submitted on slow phones by impatient users — the ones where "it worked in dev" isn't good enough.

Who this is for

  • You've built forms with raw HTML and React state but they get messy once you add validation
  • You've heard of react-hook-form and Formik but you're not sure if you actually need them
  • You want forms that work for screen reader users and keyboard users, not just mouse users

You can skip this if you're already using a schema validation library and a form library together and you know why. Jump to the Troubleshooting section if you've got a specific bug.

What is a controlled input?

A controlled input is an input whose value is driven by React state. React is in charge of what the input shows, not the browser's own form state.

Plain English: you keep the value in useState, you pass it to the input's value prop, and you update it with onChange. The browser input reflects what React says.

Simple idea: controlled = React owns the value. Uncontrolled = the DOM owns it and you read it with a ref. Controlled inputs are easier to validate, easier to reset, and easier to test.

Prerequisites

  • You can write React function components with useState
  • You know what an HTML <form> element is — the onSubmit event matters here
  • TypeScript basics help but the patterns work in plain JS too

Key terms

Controlled input — an input whose value and onChange are both wired to React state.

Validation — checking that the user's input meets the rules before you do anything with it.

Error message — feedback shown below (or near) a field telling the user what's wrong. Should be visible text, not just a red border.

Accessible label — a <label> element or aria-label that connects text to a form field so screen readers can announce it.

Zod — a TypeScript-first schema validation library. You define the shape and rules; it parses and validates for you.

Premature abstraction — reaching for a form library before you have a problem it solves, usually adding more API surface than the problem warranted.

Setup from zero

Step 1 — A minimal controlled form

import { useState } from "react";

export function ContactForm() {
  const [email, setEmail] = useState("");

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

Three things to notice: the label uses htmlFor pointing to the input's id — that's what connects them for screen readers. The input has type="email" so the browser's email keyboard appears on mobile. And e.preventDefault() stops the default page reload.

Step 2 — Adding validation state

const [email, setEmail] = useState("");
const [error, setError] = useState("");

function validate(value: string): string {
  if (!value) return "Email is required.";
  if (!value.includes("@")) return "Enter a valid email address.";
  return "";
}

function handleBlur() {
  setError(validate(email));
}

function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  const err = validate(email);
  if (err) {
    setError(err);
    return;
  }
  // submit
}

Validate on blur (when the user leaves a field) and again on submit. Don't validate on every keystroke while the user is still typing — it's annoying, and the field is always wrong for the first half of an email address.

Step 3 — Wiring the error to the input accessibly

<div>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    type="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    onBlur={handleBlur}
    aria-describedby={error ? "email-error" : undefined}
    aria-invalid={error ? true : undefined}
  />
  {error && (
    <p id="email-error" role="alert">
      {error}
    </p>
  )}
</div>

aria-describedby points the input at the error paragraph so a screen reader announces the error when the input is focused. aria-invalid tells assistive technology the field has an error. role="alert" on the error paragraph makes screen readers announce it immediately when it appears in the DOM.

The mental model

Forms have two concerns: collecting user input and communicating that input's validity back to the user. Separate those two things. The input handling (controlled state, onChange) is one system. The validation and error display is another. They talk to each other through the same state slice, but they have different jobs. Keep your validation logic in a plain function — not tangled into the onChange handler — so you can call it from multiple places and test it in isolation.

Step-by-step

Multi-field form with touched state

type FormState = { name: string; email: string };
type Errors = Partial<Record<keyof FormState, string>>;

function validate(values: FormState): Errors {
  const errors: Errors = {};
  if (!values.name.trim()) errors.name = "Name is required.";
  if (!values.email.includes("@")) errors.email = "Enter a valid email.";
  return errors;
}

export function SignupForm() {
  const [values, setValues] = useState<FormState>({ name: "", email: "" });
  const [errors, setErrors] = useState<Errors>({});
  const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
    if (touched[name as keyof FormState]) {
      setErrors(validate({ ...values, [name]: value }));
    }
  }

  function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
    const { name } = e.target;
    setTouched((prev) => ({ ...prev, [name]: true }));
    setErrors(validate(values));
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const errs = validate(values);
    if (Object.keys(errs).length) {
      setErrors(errs);
      setTouched({ name: true, email: true });
      return;
    }
    // submit values
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          value={values.name}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-describedby={errors.name && touched.name ? "name-error" : undefined}
          aria-invalid={errors.name && touched.name ? true : undefined}
        />
        {errors.name && touched.name && (
          <p id="name-error" role="alert">{errors.name}</p>
        )}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-describedby={errors.email && touched.email ? "email-error" : undefined}
          aria-invalid={errors.email && touched.email ? true : undefined}
        />
        {errors.email && touched.email && (
          <p id="email-error" role="alert">{errors.email}</p>
        )}
      </div>
      <button type="submit">Sign up</button>
    </form>
  );
}

Notice noValidate on the form — this turns off the browser's built-in validation UI so you're fully in control. The touched map tracks which fields the user has visited so you don't blast all errors before they've typed anything.

Working examples

Zod for validation logic

Zod is worth reaching for when your validation rules grow: required fields, min/max lengths, email format, confirmed passwords. You define the schema once and get both the TypeScript type and the validation logic from it.

import { z } from "zod";

const schema = z.object({
  name: z.string().min(1, "Name is required."),
  email: z.string().email("Enter a valid email address."),
});

type FormState = z.infer<typeof schema>;

function validate(values: unknown): Errors {
  const result = schema.safeParse(values);
  if (result.success) return {};
  return Object.fromEntries(
    result.error.errors.map((e) => [e.path[0], e.message])
  ) as Errors;
}

You still wire the form up yourself. Zod just handles the rules. You get the types for free, the error messages are in one place, and the schema can be reused on the server for the API route that handles submission.

Little tip: safeParse returns { success: true, data } or { success: false, error } — it never throws. That's what you want in a form handler. The throwing parse is for when invalid data is a programming error, not user input.

Disabling the submit button correctly

const hasErrors = Object.keys(validate(values)).length > 0;

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

Don't disable the submit button as the primary way to surface validation errors — users won't know why they can't submit. Show errors when fields are touched, keep the button enabled until submit is attempted, and let the submit handler catch anything missed. But disabling during an in-flight request is always right; it prevents double-submits.

Little tip: set aria-busy="true" on the form element while submitting, not just on the button. Some users tab through the form checking their answers — they'll notice the busy state announced on the form itself.

Patterns / when to use

When raw controlled inputs are enough — single-page forms, contact forms, small signup flows. Keep it. Don't add a library for four fields.

When to reach for react-hook-form — forms with 10+ fields, complex cross-field validation, repeated field arrays (e.g. "add another address"), or when you've already hit a performance wall from re-renders on every keystroke. react-hook-form uses uncontrolled inputs under the hood and only re-renders on blur or submit.

When Zod alone is enough — you need the server to validate the same rules as the client. Define the schema once, safeParse on both sides.

Common mistakes

Validating on every keystroke — the email field is always "invalid" while the user is typing the first half. Validate on blur and on submit, not onChange.

Styling errors only with color — red borders are invisible to colorblind users. Always accompany a color change with visible text and (optionally) an icon.

Missing htmlFor / id pairing — a label that isn't connected to an input does nothing for assistive technology. Wrapping the input inside the label also works, but the for/id pattern is more reliable with complex layouts.

Using type="text" for everythingtype="email", type="tel", type="number", and type="url" tell mobile browsers which keyboard to show and give you some free browser validation to layer on top of your own.

Clearing all errors on any change — resetting the error object on every keystroke means the user loses error context while correcting a different field.

Troubleshooting

Input isn't updating — you set value but forgot onChange. Without onChange, a controlled input is read-only. Add the handler.

Form submits with stale values — you're closing over state in an event handler that captured an old snapshot. Validate directly from the current state reference at submit time rather than relying on a closure.

Screen reader doesn't announce the errorrole="alert" only fires when the element appears in the DOM. If the element is always there but hidden with CSS, the announcement won't trigger. Remove it from the DOM when there's no error; add it back when there is.

Browser autocomplete breaks controlled state — some browsers autofill before React's onChange fires. Add autoComplete="off" on fields where autocomplete causes real problems (use sparingly — autocomplete helps most users).

Zod error paths are nested — for nested schemas, e.path is an array like ["address", "city"]. Flatten it with e.path.join(".") to use as an object key in your errors map.

Checklist

  • [ ] Every input has a <label> connected via htmlFor/id (or aria-label if no visible label is possible)
  • [ ] Error messages are visible text near the field, not only color changes
  • [ ] aria-describedby on each input points to its error message id
  • [ ] aria-invalid="true" set on fields with active errors
  • [ ] role="alert" on error paragraphs so screen readers announce them on appearance
  • [ ] Validation runs on blur and on submit — not on every keystroke while typing
  • [ ] Submit button disabled only during in-flight requests, not as primary validation feedback
  • [ ] noValidate on the form when managing all validation manually
  • [ ] type attribute is correct for each input (email, tel, number, url)
  • [ ] Zod schema shared between client and server if both validate the same shape

Practice task

Build a two-step signup form. Step 1 collects name and email with blur validation and error messages. Step 2 collects a password with a minimum length of 8 characters. Don't advance to step 2 until step 1 is valid. On final submit, log the collected values and show a success message. Add keyboard navigation so the user can tab through everything without touching the mouse. Optionally swap the validation function to use a Zod schema — notice how little else changes.

FAQ

Do I need react-hook-form for every form?
No. For simple forms with a handful of fields, vanilla controlled inputs and a validate function are clearer and have zero dependencies. Reach for a library when you hit a real problem — performance under heavy input, complex field arrays, or you just want the DX it provides.

What about Server Actions in Next.js?
Server Actions let you submit a form to a server function directly without writing an API route. The form's action prop points to an async server function. You still validate on the server (Zod works well here) and return errors back to the client. They work with and without JavaScript enabled — a genuine accessibility win for progressive enhancement.

Is Formik still a good choice?
react-hook-form has largely replaced Formik in recent projects — better performance, smaller bundle, better TypeScript types. Formik is fine if you're already using it, but starting fresh, react-hook-form is the current default when you need a library.

Should I validate on change or on blur?
Blur for fields the user is actively typing in. Change once a field has already been touched and has an error (so the error clears as they fix it). Submit to catch anything they skipped entirely.

What to learn next

  • React Testing Library — the companion post shows how to test these forms without testing implementation details, so your tests survive refactors
  • Server Actions — the Next.js App Router approach to form submission, with built-in progressive enhancement and no API route needed
  • Zod docs — once you've used safeParse for forms, the transform and refinement APIs open up much more complex validation scenarios
  • [React Testing Library basics](/developers/react/react-testing-library-basics)
  • [React hooks patterns in 2026](/developers/react/react-hooks-patterns-2026)
  • [Next.js App Router guide](/developers/nextjs/nextjs-app-router-guide)

Takeaways

Forms get messy fast because validation state, touched state, and submission state are all tangled together. Keep the validation logic in a plain function you can call from multiple places. Always label inputs visibly and connect them to error messages with aria-describedby. Don't reach for a form library until you have a problem it solves — most forms don't need one.

If you remember only one thing: validate on blur, not on every keystroke. It's the single change that most improves form UX for real users filling in real forms.