Checkboxes, selects, and textareas
Real forms are more than text boxes: a signup page has a plan dropdown, a newsletter checkbox, a bio field. In plain HTML each control reports its value differently, which is exactly the inconsistency React's controlled pattern smooths over. Every control becomes the same loop from lesson 6-1, state in, onChange out, with two adjustments worth knowing before your first real form.
Textareas take value, not children. In HTML the text sits between the tags: <textarea>hello</textarea>. JSX moves it to the value prop so a textarea is controlled exactly like an input, one consistent pattern instead of a special case:
<textarea value={bio} onChange={e => setBio(e.target.value)} />Selects take value on the <select>, not selected on an option. HTML marks the chosen <option> with a selected attribute, scattering the answer across the children. React centralizes it, the select's value prop decides which option shows, so the current choice lives in one place: your state.
<select value={plan} onChange={e => setPlan(e.target.value)}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>Checkboxes: checked, not value
A checkbox's value attribute is a constant label that never changes when you click, the fact that does change is the boolean checked. So a controlled checkbox pairs checked with e.target.checked:
<input type="checkbox" checked={newsletter} onChange={e => setNewsletter(e.target.checked)} />
With several mixed controls, one generic handler beats a handler per field. Give each control a name attribute matching its field in the form object, then branch on the control type, this is the multi-field spread pattern from lesson 6-2, generalized:
function handleChange(e) { const value = e.target.type === "checkbox" ? e.target.checked : e.target.value; setForm({ ...form, [e.target.name]: value }); }
One function now serves the dropdown, the checkbox, and the textarea. This exact helper appears in countless production codebases.
One handler for every control type
handleChange(form, target) reads target.checked for a checkbox and target.value for anything else, then returns a new form object with only the named field updated.
function handleChange(form, target) { const value = target.type === "checkbox" ? target.checked : target.value; return { ...form, [target.name]: value }; } let form = { plan: "free", newsletter: false, bio: "" }; form = handleChange(form, { name: "plan", type: "select-one", value: "pro" }); form = handleChange(form, { name: "newsletter", type: "checkbox", checked: true }); form = handleChange(form, { name: "bio", type: "textarea", value: "I build things." }); console.log(JSON.stringify(form));
Output
{"plan":"pro","newsletter":true,"bio":"I build things."}The ternary picks which property to read, and the computed property name [target.name] routes the result to the right field. Those two lines are the whole generalization, and they are why a ten-control form needs one handler instead of ten.
Each call returns a new object rather than assigning into form, which is the immutability rule from lesson 4-3. The reassignment form = handleChange(...) stands in for setForm(...), and note that it replaces the reference rather than editing the object.
The three simulated events cover the three control types from this lesson, and only the checkbox takes the checked branch. select-one is the real type value a browser reports for a single-choice select, so the ternary's else branch handles selects, textareas, and text inputs alike.
name is the connective tissue that makes this work, and it has to match the field name in the form object exactly. A typo there adds a new field instead of updating an existing one, and nothing throws, so the input appears to accept text that never reaches the field it was meant for.
A checkbox wired to the wrong property
const [agreed, setAgreed] = useState(false); return <input type="checkbox" value={agreed} onChange={e => setAgreed(e.target.value)} />;
The bug is that it uses value and e.target.value, and a checkbox's changing fact is the boolean checked, so it needs checked={agreed} and e.target.checked.
A checkbox's value attribute is a fixed label that does not flip when the user clicks, so reading it gives the same string every time. e.target.value on a checkbox with no explicit value is the string "on", which is truthy, so state becomes "on" and never returns to false.
Nothing warns about this, which is what makes it worth memorizing. The box even appears to work on the first click, since a truthy state renders as checked, and it can never be unchecked afterward.
| Control | Controlled prop | Read from event |
|---|---|---|
| text input | value | e.target.value |
| textarea | value | e.target.value |
| select | value | e.target.value |
| checkbox | checked | e.target.checked |
Three of the four rows are identical, which is exactly why the fourth catches people. The checkbox is the only control in everyday use whose state is a boolean rather than a string.
Where a controlled dropdown keeps its choice
React puts it in the value prop of the <select> itself, read from state.
<select value={plan}> shows whichever option's value matches plan, so the choice is centralized in one place and the options are just a list of possibilities.
HTML's approach marks the chosen option with a selected attribute on one of the children, which scatters a single fact across many elements. That is against the one-source-of-truth principle from lesson 6-1, and it also means adding an option requires knowing which sibling currently holds the mark.
The centralized version has a practical consequence worth expecting. If plan holds a string that no option carries, the select renders as blank rather than as an error, so a typo in an option's value or a mismatched initial state shows up as an empty dropdown.
Note that React warns if you use selected on an option in JSX, and points you at value on the select or at defaultValue for the uncontrolled case. That warning is the library telling you which of the two models you are in.