Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 7 of 17 · ~4 min

Forms and Controlled Components

Time to build the inside of the drawer. A reviewer needs to leave notes, give a rating, and tick whether this person should go to an onsite.

A controlled input is one whose displayed value comes from React state, so the DOM node is never holding a value React does not already know:

function ReviewForm() {
  const [notes, setNotes] = useState("");

  return (
    <textarea
      value={notes}
      onChange={(e) => setNotes(e.target.value)}
    />
  );
}

Every keystroke fires onChange, which sets state, which re-renders the textarea with the new state as its value. The reviewer sees their character appear instantly, and it arrived by a full round trip through React.

Two details that produce confusing warnings if you get them half right. Passing value without onChange gives you an input the user cannot type into, and React warns about exactly that. And an input that starts with value={undefined} and later receives a string flips from uncontrolled to controlled mid-life, which React also warns about — initialise text state to "", not undefined or null.

The uncontrolled alternative lets the DOM keep its own value, and you read it when you need it:

function QuickNote({ onSave }) {
  const noteRef = useRef(null);
  return (
    <form onSubmit={(e) => { e.preventDefault(); onSave(noteRef.current.value); }}>
      <input ref={noteRef} defaultValue="" />
    </form>
  );
}

That is less code and it re-renders nothing while the user types. What you give up is the ability to react to each keystroke: no live validation, no character counter, no disabling the save button until something is typed, no derived state at all. Note defaultValue rather than value — that is the uncontrolled spelling of an initial value. Refs are chapter 8.

Multiple fields: one object, or one useState each

Our form has three fields, and the difference in approach shows up immediately:

function ReviewForm({ candidateId, onSave }) {
  const [form, setForm] = useState({ notes: "", rating: "3", onsite: false });

  function handleChange(e) {
    const { name, type, value, checked } = e.target;
    setForm((prev) => ({
      ...prev,                                     // keep the other fields
      [name]: type === "checkbox" ? checked : value,   // update just this one
    }));
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); onSave(candidateId, form); }}>
      <textarea name="notes" value={form.notes} onChange={handleChange} />
      <select name="rating" value={form.rating} onChange={handleChange}>
        <option value="1">Strong no</option>
        <option value="3">Mixed</option>
        <option value="5">Strong yes</option>
      </select>
      <label>
        <input name="onsite" type="checkbox" checked={form.onsite} onChange={handleChange} />
        Recommend for onsite
      </label>
    </form>
  );
}

One handler covers every field, because the computed key [name]: ... looks up which field to write from the input's own name attribute. Add a fourth input and the handler does not change. With a separate useState per field you would be writing a fourth onChange.

Three things in that snippet are doing more work than they look like they are.

The spread is not stylistic. { ...prev, notes: value } builds a new object. React decides whether state changed by comparing with Object.is, so mutating prev.notes and passing prev back gives React the same reference it already had, and it skips the render. A form that "sometimes doesn't update" is this bug more often than not.

Checkboxes use checked, not value, and report through e.target.checked. A checkbox's value is the string submitted when it is ticked, which is almost never what you want in React.

<select> is controlled through value on the select itself, not selected on an option — a place where React deliberately diverges from HTML to make the pattern uniform.

Real forms outgrow this. The moment you need per-field validation, touched/dirty tracking, error messages, async checks or nested field arrays, hand-rolling it stops being a saving. React Hook Form and Formik exist for that, and reaching for one is the normal choice in production. React 19 also added first-class form actions — passing a function to <form action={...}>, with useActionState for the pending/result state and useFormStatus for a submit button that knows it is submitting — which is worth knowing about, particularly in a framework that does server-side work.

One accessibility note, since we are in a form: labels need htmlFor matching an input id, and if the same form renders more than once on a page, hardcoded ids collide. useId() generates a stable unique id that matches between server and client rendering, which is what it exists for.