Forms

Put the form in state, keep one handler for every field, and validate before you submit.

09of 20
≈ 12Min read
8Parts

Controlled: the value lives in state

The input shows what state says. Without the onChange it would be read-only and React would warn about it — the value could never change because nothing ever changed the state.

the loop that defines a controlled input
const [name, setName] = React.useState("");

<input value={name} onChange={(e) => setName(e.target.value)} />

// state → value → the user types → onChange → state → render

One handler for a whole form

name the fields and reuse it
const [form, setForm] = React.useState({ name: "", email: "", express: false });

function handleChange(event) {
  const { name, value, type, checked } = event.target;
  setForm((f) => ({ ...f, [name]: type === "checkbox" ? checked : value }));
}

<input name="name" value={form.name} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} />
<input name="express" type="checkbox" checked={form.express} onChange={handleChange} />
The computed key

[name]: value uses the field’s name attribute as the key. One handler then covers ten fields, and adding a field means adding markup only.

Each control, and what it binds

ControlPropRead from
text, email, number, textareavaluee.target.value (always a string)
checkboxcheckede.target.checked
radio groupchecked={value === "x"}e.target.value
selectvalue on the <select>e.target.value
multi-selectvalue={array}[...e.target.selectedOptions].map(o => o.value)
file— (never controlled)e.target.files, with a ref
number inputs give you strings

"5", not 5. And an empty number input gives "", not 0 — so Number(value) on an empty field is 0, which is a different thing. Convert at the edge, and keep the raw string in state while typing.

Uncontrolled, and when it is better

Controlled is the default because the value is available at every keystroke — for live validation, a disabled button, a character count. Uncontrolled is fine, and less code, when nothing needs the value until submit. Use defaultValue / defaultChecked, never value without onChange.

let the DOM hold it and read it at the end
function Search({ onSearch }) {
  const input = React.useRef(null);
  return (
    <form onSubmit={(e) => { e.preventDefault(); onSearch(input.current.value); }}>
      <input ref={input} defaultValue="" />
    </form>
  );
}

Submitting

  • onSubmit on the <form> catches Enter in a text field as well as the button. A click handler on the button does not.
  • Every <button> inside a form is type="submit" by default. Give the others type="button" or they will submit it.
  • noValidate turns off the browser bubbles when you are showing your own messages — keep the required and type attributes anyway, for assistive technology.
on the form, not on the button
function handleSubmit(event) {
  event.preventDefault();          // or the page reloads
  const problems = validate(form);
  setErrors(problems);
  if (Object.keys(problems).length > 0) return;
  save(form);
}

<form onSubmit={handleSubmit} noValidate>
  …
  <button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</button>
</form>

Validation people can live with

Shouting "required" at an empty field the moment it is focused is hostile. Validate on submit; once the user has tried, validate as they type so they can see the problem go away.

validate on submit, then on change
const [errors, setErrors] = React.useState({});
const [tried, setTried] = React.useState(false);

function validate(f) {
  const e = {};
  if (!f.name.trim()) e.name = "Name is required";
  if (!/^\S+@\S+\.\S+$/.test(f.email)) e.email = "That email looks wrong";
  return e;
}

<input name="email" value={form.email} onChange={handleChange}
  aria-invalid={!!errors.email} aria-describedby={errors.email ? "email-error" : undefined} />
{errors.email && <small id="email-error" role="alert">{errors.email}</small>}

Accessibility, briefly

  • Every input needs a <label htmlFor>. A placeholder is not a label — it disappears exactly when it is needed.
  • Errors: role="alert" so they are announced, tied to the input with aria-describedby.
  • autoComplete="email", "name", "tel" — real names, free autofill.
  • inputMode="numeric" gives phones the right keyboard without making the field a number input.

When to reach for a library

Two or three fields do not need one. A long form with cross-field rules, async checks and arrays of rows is where React Hook Form or TanStack Form starts paying for itself — mostly by keeping re-renders down and by giving errors and touched state a single shape.

Practice