Runnable lesson · press Try it on the examples to edit and run them here.
You have met controlled inputs and a simple form. Now let us build a realistic controlled form with several fields and see the pattern that scales: one state object, one change handler, and everything else derived.
The pattern
- Keep all field values in one state object.
- Update it with one
handleChangethat uses each input’sname. - Derive things such as “is the form valid” instead of storing them.
- Handle
onSubmitonce, withpreventDefault.
function App() {
const [form, setForm] = React.useState({
username: "",
email: "",
accepted: false,
});
const [result, setResult] = React.useState("");
function handleChange(event) {
const { name, value, type, checked } = event.target;
setForm({
...form,
[name]: type === "checkbox" ? checked : value,
});
}
// Derived, not stored in state
const isValid = form.username.trim() !== "" && form.email.includes("@") && form.accepted;
function handleSubmit(event) {
event.preventDefault();
if (!isValid) return;
setResult("Account created for " + form.username);
}
return (
<form onSubmit={handleSubmit} style={{ display: "grid", gap: 10, maxWidth: 280 }}>
<label>
Username
<input name="username" aria-label="Username" value={form.username} onChange={handleChange} />
</label>
<label>
Email
<input name="email" type="email" aria-label="Email" value={form.email} onChange={handleChange} />
</label>
<label>
<input name="accepted" type="checkbox" checked={form.accepted} onChange={handleChange} />
I accept the terms
</label>
<button type="submit" disabled={!isValid}>Create account</button>
<p role="status">{result}</p>
</form>
);
}
What makes this scale
- Adding a field means adding one key to the initial state and one input with a
name. The handler does not change. - Checkboxes need
checked, notvalue; the handler picks the right one withtype. isValidis recalculated from state on every render, so the button is disabled or enabled correctly with no extra bookkeeping.
Live preview of the form’s data
Because the data is in state, you can show it anywhere. This is handy while building and debugging:
function App() {
const [form, setForm] = React.useState({ name: "", role: "student" });
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
return (
<div>
<input name="name" aria-label="Name" value={form.name} onChange={handleChange} />
<select name="role" aria-label="Role" value={form.role} onChange={handleChange}>
<option value="student">Student</option>
<option value="teacher">Teacher</option>
</select>
<h4>Live data</h4>
<pre>{JSON.stringify(form, null, 2)}</pre>
</div>
);
}
Controlled vs uncontrolled for forms
If you do not need live feedback, an uncontrolled form read with FormData on submit is shorter (Form Handling). Choose controlled when you want to validate while typing, format, preview, or enable and disable buttons from the data.
Common mistakes
- Separate handler per field when a single
name-based handler works. value={undefined}at the start: use empty strings to keep inputs controlled.- Forgetting
nameon an input, so the handler writes to the keyundefined. - Storing
isValidorerrorsin state when they can be calculated; see Derived State.
Key points
- One state object plus one
name-based handler scales to many fields. - Derive validity, error text and button state from the form data.
- Checkboxes use
checked; everything else usesvalue.