Handling Multiple Inputs
Real forms rarely have just one field — sign-up forms, checkout forms, and settings pages all ask for several pieces of data at once. Writing a separate useState call and a separate handler for every single input quickly becomes repetitive and hard to maintain. React’s idiomatic solution is to store all the related fields in a single state object and use one shared change handler that updates the correct key based on the input’s name attribute. This lesson shows you exactly how that pattern works, why it works, and the mistakes that trip people up.
Overview / How It Works
A form input in React is usually a controlled component: its displayed value is driven entirely by React state (value={state}), and every keystroke fires an onChange event that updates that state. When you have many fields, you don’t want ten different pieces of state and ten different handlers. Instead, you keep one object in state, where each key corresponds to one field:
const [formData, setFormData] = useState({
name: "",
email: "",
});
Then you give every <input> a name attribute that exactly matches its key in the state object, and point all of them at the same handleChange function. Inside that function, you read event.target.name and event.target.value to figure out which field changed and what it changed to, then use a computed property name ([name]: value) to update only that one key while spreading the rest of the object unchanged.
This works because of two JavaScript features you already know from this site’s JS course: object spread (...prevData) and computed property names ({ [name]: value }). React itself does nothing special here — the “multiple inputs” pattern is really just “one object in state, updated immutably, keyed by the DOM’s own name attribute.”
Remember that state must always be treated as immutable. You never assign directly into formData; you always call setFormData with a brand-new object. This matters for two reasons: React compares state by reference to decide whether to re-render, and mutating the existing object in place can silently break that comparison so your UI doesn’t update, or updates inconsistently on the next render.
Every time setFormData runs, React schedules a re-render of the component. React re-executes the function component, builds a new Virtual DOM tree, diffs it against the previous tree (reconciliation), and commits only the minimal set of real DOM changes — in this case, updating the value of the one input whose backing state actually changed. The other inputs’ DOM nodes are left untouched because their part of the tree is unchanged after the diff.
Syntax
const [formData, setFormData] = useState({ field1: "", field2: "" });
function handleChange(event) {
const { name, value } = event.target;
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
}
<input name="field1" value={formData.field1} onChange={handleChange} />
<input name="field2" value={formData.field2} onChange={handleChange} />
| Part | Purpose |
|---|---|
formData |
A single state object holding the current value of every field. |
name attribute |
Identifies which key in formData this input controls. Must match the object key exactly. |
event.target.name |
Read inside the handler to know which field triggered the change. |
event.target.value |
The new value typed by the user (for checkboxes, use event.target.checked instead). |
[name]: value |
A computed property name — writes into whichever key name currently holds. |
...prevData |
Spreads the previous state so untouched fields are preserved. |
Examples
Example 1: A Basic Two-Field Form
import { useState } from "react";
function ContactForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
});
function handleChange(event) {
const { name, value } = event.target;
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
}
function handleSubmit(event) {
event.preventDefault();
console.log(formData);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
/>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<button type="submit">Submit</button>
</form>
);
}
export default ContactForm;
Output:
{ name: "Ada Lovelace", email: "ada@example.com" }
Both inputs share the exact same handleChange function. Because each input carries its own name, the handler knows exactly which key of formData to update, and the other field is preserved untouched by the spread. Submitting the form logs the whole object.
Example 2: Mixing Text, Select, and Checkbox Inputs
import { useState } from "react";
function PreferencesForm() {
const [formData, setFormData] = useState({
username: "",
plan: "free",
newsletter: false,
});
function handleChange(event) {
const { name, value, type, checked } = event.target;
setFormData((prevData) => ({
...prevData,
[name]: type === "checkbox" ? checked : value,
}));
}
return (
<form>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
type="text"
value={formData.username}
onChange={handleChange}
/>
<label htmlFor="plan">Plan</label>
<select id="plan" name="plan" value={formData.plan} onChange={handleChange}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<label htmlFor="newsletter">
<input
id="newsletter"
name="newsletter"
type="checkbox"
checked={formData.newsletter}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
<p>
Preview: {formData.username || "(no name)"} — {formData.plan} plan —{" "}
{formData.newsletter ? "subscribed" : "not subscribed"}
</p>
</form>
);
}
export default PreferencesForm;
This renders a username field, a plan dropdown, a newsletter checkbox, and a live preview paragraph that updates on every keystroke, selection, or check. The key detail is the ternary inside handleChange: a checkbox’s meaningful value lives in event.target.checked (a boolean), not event.target.value (which is always the fixed string "on" for checkboxes). Branching on event.target.type lets one handler correctly serve text inputs, selects, and checkboxes at once.
Example 3: A Realistic Signup Form With Validation
import { useState } from "react";
function SignupForm() {
const [formData, setFormData] = useState({
email: "",
password: "",
confirmPassword: "",
});
const [error, setError] = useState("");
function handleChange(event) {
const { name, value } = event.target;
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
}
function handleSubmit(event) {
event.preventDefault();
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match.");
return;
}
setError("");
console.log("Signing up with:", formData);
setFormData({ email: "", password: "", confirmPassword: "" });
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
required
/>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
required
/>
{error && <p>{error}</p>}
<button type="submit">Sign Up</button>
</form>
);
}
export default SignupForm;
This form adds a third field and a second piece of state (error) that is separate from the field data because it isn’t a form field itself — it’s derived, transient UI feedback. On submit, it compares password and confirmPassword, shows an error message if they differ, and otherwise logs the data and resets every field back to an empty string by calling setFormData with a brand-new object.
How It Works Step by Step
On mount: useState initializes formData with the given defaults, and React renders each input with its value pulled from that object.
On every keystroke: the browser fires a native change (or input, wrapped by React) event, React wraps it in a SyntheticEvent, and your handleChange runs synchronously with event.target pointing at the DOM node the user interacted with. You read name and value off that node.
State update: calling setFormData with an updater function ((prevData) => ({ ...prevData, [name]: value })) queues a re-render. Using the updater form rather than referencing the outer formData directly guards against stale values if multiple updates are batched together.
Re-render and reconciliation: React re-invokes the component function, produces a new element tree, and diffs it against the previous one. Only the one <input> whose value prop actually changed gets its real DOM node updated — the rest are structurally identical and are left alone.
On submit: your handler calls event.preventDefault() to stop the browser’s default full-page reload, then does whatever you need with the current formData — validate it, send it to an API, or reset it with another setFormData call.
Common Mistakes
Mistake 1: Mutating the state object directly
function handleChange(event) {
const { name, value } = event.target;
formData[name] = value; // mutates the existing object
setFormData(formData); // same reference — React may skip the re-render
}
React decides whether to re-render partly by checking if the new state is a different reference from the old one. Assigning directly onto formData and passing that same object back to setFormData can result in inconsistent UI, since React has no reliable signal that anything changed. Always build a new object instead:
function handleChange(event) {
const { name, value } = event.target;
setFormData((prevData) => ({ ...prevData, [name]: value }));
}
Mistake 2: Hardcoding the key instead of using the input’s name
function handleChange(event) {
setFormData({ ...formData, name: event.target.value });
}
<input name="email" onChange={handleChange} value={formData.email} />
<input name="password" onChange={handleChange} value={formData.password} />
This handler always writes into the literal key "name", no matter which input fired the event — so typing in the email or password field silently does nothing useful, or worse, creates an unrelated name key on the object. The fix is the computed property name driven by event.target.name:
function handleChange(event) {
const { name, value } = event.target;
setFormData((prevData) => ({ ...prevData, [name]: value }));
}
Mistake 3: Using value instead of checked for checkboxes
<input
name="newsletter"
type="checkbox"
value={formData.newsletter}
onChange={handleChange}
/>
A checkbox’s ticked/unticked state is controlled by the checked prop, not value. Passing a boolean into value leaves the checkbox uncontrolled by React and can trigger a console warning about switching between controlled and uncontrolled inputs. Use checked={formData.newsletter} and read event.target.checked in the handler, as shown in Example 2.
Best Practices
- Keep every
nameattribute in exact sync with its state key — a typo here silently breaks that one field. - Always update state immutably with spread syntax, never by assigning into the existing object or array.
- Branch on
event.target.typeinside a shared handler so checkboxes (checked) and text/select inputs (value) are both handled correctly. - Use the updater-function form of
setState((prev) => ({ ...prev, ... })) when the new value depends on the previous state, to avoid stale-closure bugs. - For forms with many interdependent fields or complex validation, consider
useReducerinstead of severaluseStatecalls — it centralizes the update logic in one reducer function. - Reset a form by calling
setFormDatawith a fresh object matching your initial shape, rather than manually clearing each input. - Extract a reusable labeled-input component if your form has many similar fields, to avoid repeating the same JSX structure.
Practice Exercises
- Build a
ProfileFormcomponent withname,email, andbio(a<textarea>) fields, all driven by a singleformDatastate object and one sharedhandleChangefunction. - Add a “Subscribe to newsletter” checkbox to the form above. Make sure it reads and writes
event.target.checkedcorrectly and that the preview text updates live. - Rewrite the
SignupFormfrom Example 3 to manage its fields withuseReducerinstead ofuseState, using an action shaped like{ type: "field", name, value }.
Summary
- Store related form fields in a single state object rather than one
useStateper field. - Give every input a
nameattribute matching its key, and use onehandleChangefunction for all of them. - Read
event.target.nameandevent.target.valueand update state immutably with a computed property name and spread syntax. - Checkboxes use
checkedandevent.target.checked, notvalue. - Every
setFormDatacall triggers React’s render → reconcile → commit cycle, updating only the DOM nodes that actually changed. - Never mutate state directly — always produce a new object so React can reliably detect the change.
