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.
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
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} />
[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
| Control | Prop | Read from |
|---|---|---|
| text, email, number, textarea | value | e.target.value (always a string) |
| checkbox | checked | e.target.checked |
| radio group | checked={value === "x"} | e.target.value |
| select | value on the <select> | e.target.value |
| multi-select | value={array} | [...e.target.selectedOptions].map(o => o.value) |
| file | — (never controlled) | e.target.files, with a ref |
"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.
function Search({ onSearch }) {
const input = React.useRef(null);
return (
<form onSubmit={(e) => { e.preventDefault(); onSearch(input.current.value); }}>
<input ref={input} defaultValue="" />
</form>
);
}
Submitting
onSubmiton 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 istype="submit"by default. Give the otherstype="button"or they will submit it. noValidateturns off the browser bubbles when you are showing your own messages — keep therequiredandtypeattributes anyway, for assistive technology.
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.
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 witharia-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.