Skip to the chapter
←ReactGGroundwork⚛React
React/Beginner/B8
8 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛B8 · Part 1 · Beginner

Controlled forms

The input shows what state says, and typing asks state to change.

4 min read9 sections5 exercises
8 of 57

In this chapter

  • Controlled: state is the source of truth
  • The input types that are different
  • One object for a whole form
  • Submitting
  • The uncontrolled alternative

and 4 more

about 4 min
09On this page
  1. Controlled: state is the source of truth
  2. The input types that are different
  3. One object for a whole form
  4. Submitting
  5. The uncontrolled alternative
  6. Validation that does not fight the user
  7. Labels are not optional
  8. Two edge cases that bite
  9. When to reach for a library

Controlled: state is the source of truth

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

<input
  value={email}
  onChange={(e) => setEmail(e.target.value)}
/>

The loop is: state renders the value → you type → onChange fires → state updates → re-render puts the new value in the input. The DOM never holds anything React does not know about.

Which is why value without onChange gives you a read-only input and a console warning — the input shows state, and without a handler nothing ever changes state.

The undefined-to-defined warning

useState() with no argument starts as undefined, which React treats as "uncontrolled". The first keystroke gives it a value and it becomes controlled — and React warns that the input changed kind mid-life. Always initialise to "", never to null or nothing.

The input types that are different

// checkbox — checked, not value
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />

// number — e.target.value is always a string
<input type="number" value={qty} onChange={(e) => setQty(Number(e.target.value))} />

// select — value on the select, not selected on the option
<select value={city} onChange={(e) => setCity(e.target.value)}>
  <option value="pune">Pune</option>
</select>

// textarea — value as a prop, not as children
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />

// file — uncontrolled by law; read it from the event
<input type="file" onChange={(e) => setFile(e.target.files[0])} />

One object for a whole form

Six fields do not need six useState calls. Give each input a name and share one handler.

const [form, setForm] = useState({ name: "", email: "", city: "" });

function handleChange(e) {
  const { name, value, type, checked } = e.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} />

The updater form matters here. setForm({ ...form, ... }) reads the snapshot, which goes wrong the moment two fields change in one tick.

Submitting

<form onSubmit={handleSubmit}>
  ...
  <button type="submit">Save</button>
</form>

async function handleSubmit(e) {
  e.preventDefault();          // or the page reloads
  setSaving(true);
  try {
    await save(form);
  } catch (err) {
    setError(err.message);
  } finally {
    setSaving(false);          // runs on both paths
  }
}

Put the handler on the <form>, not on the button. That way pressing Enter in a text field submits, which is what people expect and what screen readers announce.

A button inside a form defaults to type="submit". A Cancel button that reloads your page is almost always a missing type="button".

The uncontrolled alternative

Controlled inputs re-render on every keystroke. Usually that is fine. When it is not — a long form, a slow parent — let the DOM hold the values and read them at submit.

function Form({ onSave }) {
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(e.currentTarget));
      onSave(data);                       // { name: "...", email: "..." }
    }}>
      <input name="name" defaultValue="" />
      <input name="email" type="email" />
      <button>Save</button>
    </form>
  );
}

defaultValue rather than value is what makes an input uncontrolled: React sets it once and then leaves it alone. FormData reads every named field with no state at all.

ControlledUncontrolled
Source of truthReact stateThe DOM
Live validation, formatting as you typeeasyno
Disable submit until valideasyawkward
Re-renders while typingevery keystrokenone
Code for a plain formmorealmost none

Default to controlled. Reach for uncontrolled when the form is large and does nothing until submit — and note that React 19's form actions are built on exactly this shape.

Validation that does not fight the user

Validating on every keystroke means telling somebody their email is invalid while they are still typing the @. Validate on blur and on submit; once a field has an error, then it is fair to update it live so they can see it clear.

const [touched, setTouched] = useState({});
const errors = validate(form);              // derived, not state
const showError = (field) => touched[field] && errors[field];

<input
  name="email"
  value={form.email}
  onChange={handleChange}
  onBlur={() => setTouched((t) => ({ ...t, email: true }))}
  aria-invalid={!!showError("email")}
  aria-describedby="email-error"
/>
{showError("email") && <p id="email-error" className="error">{errors.email}</p>}

Note that errors is computed during render rather than stored. It is derived from form, so keeping it in state would just be a copy that can go stale.

Do not disable the submit button

A disabled button gives no reason and cannot be focused, so a keyboard or screen-reader user is stuck with no explanation. Leave it enabled, let the submit run the validation, and move focus to the first error. Disable it only while the request is in flight, to prevent a double submit.

Labels are not optional

<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" />

A placeholder is not a label: it disappears when typing starts, it fails contrast requirements in most designs, and it is not reliably announced. autoComplete is worth the two seconds — it is the difference between a form the browser can fill and one it cannot.

Two edge cases that bite

The cursor jumps to the end

<input value={value.toUpperCase()} onChange={(e) => setValue(e.target.value)} />

Transforming the value on the way out means the string React writes back differs from what the user typed, and the browser resets the caret to the end. Type in the middle of the word and the cursor jumps. Format on blur or on submit, not on every render.

Search that fires a request per keystroke

const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");

useEffect(() => {
  const id = setTimeout(() => setDebounced(query), 300);
  return () => clearTimeout(id);        // cancels the previous timer
}, [query]);

// then fetch on [debounced], not on [query]

The input stays controlled and instant; only the value the request depends on lags behind. The cleanup is what makes it a debounce — every keystroke cancels the pending timer.

When to reach for a library

Everything above is enough for a login, a settings panel, or a contact form. Once you have twenty fields, arrays of repeated field groups, cross-field rules or wizard steps, hand-rolling it stops being cheaper — that is what forms at scale is about.

Interview answer, one sentence

"A controlled input renders from state and reports changes back to it, so React state is the single source of truth. Validate on blur and submit rather than on every keystroke, derive the errors instead of storing them, and keep the submit button enabled so the failure can be explained."

05Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 11 testsValidate before you submitbeginner
  • 8 testsOne field, one immutable updatebeginner
  • 10 testsA signup form that validates on submitbeginner
  • 7 testsA group of checkboxes, one array of statebeginner
  • 5 testsA radio group is one value, not one boolean per optionbeginner
All 46 for this level →
B8

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. B7Previous · B7Lists with .map() and keys
  2. B8you are here
  3. Next · B9useEffect basicsFor synchronising with something outside React — and most of the time you do not need it.4 min read

01On this page

9 sections · 0%

  1. 01Controlled: state is the source of truth
  2. 02The input types that are different
  3. 03One object for a whole form
  4. 04Submitting
  5. 05The uncontrolled alternative
  6. 06Validation that does not fight the user
  7. 07Labels are not optional
  8. 08Two edge cases that bite
  9. 09When to reach for a library

[] chapters · t top