B8 · Part 1 · Beginner
Controlled forms
The input shows what state says, and typing asks state to change.
09On this page
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.
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state | The DOM |
| Live validation, formatting as you type | easy | no |
| Disable submit until valid | easy | awkward |
| Re-renders while typing | every keystroke | none |
| Code for a plain form | more | almost 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."
Opens in the editor: write it, run it, and check it against real tests.
All 46 for this level →Finished reading?
0 of 57 chapters read in this series