Runnable lesson · press Try it on the examples to edit and run them here.
Real forms have many inputs, and sometimes the number of inputs is not fixed: several email addresses, several phone numbers, a list of ingredients. This lesson handles both.
Many fixed inputs with one handler
You saw this in Controlled Forms. The handler copies the state and replaces the key named by the input:
function App() {
const [address, setAddress] = React.useState({ street: "", city: "", postcode: "" });
function handleChange(e) {
setAddress((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
return (
<fieldset>
<legend>Address</legend>
<input name="street" aria-label="Street" placeholder="Street" value={address.street} onChange={handleChange} />
<input name="city" aria-label="City" placeholder="City" value={address.city} onChange={handleChange} />
<input name="postcode" aria-label="Postcode" placeholder="Postcode" value={address.postcode} onChange={handleChange} />
<p>{[address.street, address.city, address.postcode].filter(Boolean).join(", ")}</p>
</fieldset>
);
}
We used the updater form (prev) => ... so rapid typing always builds on the latest object. The fieldset and legend group related fields for assistive technology.
A dynamic list of inputs
When the user can add and remove rows, keep an array in state, and give each row an id:
let nextId = 3;
function App() {
const [emails, setEmails] = React.useState([
{ id: 1, value: "a@example.com" },
{ id: 2, value: "" },
]);
function update(id, value) {
setEmails(emails.map((e) => (e.id === id ? { ...e, value } : e)));
}
function add() {
setEmails([...emails, { id: nextId++, value: "" }]);
}
function remove(id) {
setEmails(emails.filter((e) => e.id !== id));
}
return (
<fieldset>
<legend>Email addresses</legend>
{emails.map((email, index) => (
<div key={email.id} style={{ marginBottom: 6 }}>
<input
aria-label={"Email " + (index + 1)}
value={email.value}
onChange={(e) => update(email.id, e.target.value)}
/>{" "}
<button type="button" aria-label={"Remove email " + (index + 1)} onClick={() => remove(email.id)}>
Remove email {index + 1}
</button>
</div>
))}
<button type="button" onClick={add}>Add email</button>
<p>{emails.length} addresses</p>
</fieldset>
);
}
Important details:
key={email.id}, never the index. If you remove the first row with index keys, the typed text sticks to the wrong row (Understanding React Keys shows this bug live).- Each row’s
onChangecaptures its own id, so editing a row cannot affect others. type="button"on the Add and Remove buttons stops them from submitting a surrounding form.- The ids come from a counter, not the array position. Use
crypto.randomUUID()when you need ids that never repeat across sessions.
Nested form data
If your data is nested (user.address.city), either flatten the form state and rebuild the object on submit, or update with a nested copy:
setForm({ ...form, address: { ...form.address, city: e.target.value } });
Flat state is usually easier to handle. See Updating Objects in State.
Which approach for many fields?
| Situation | Approach |
|---|---|
| Fixed fields | One state object + one name handler |
| Rows added and removed by the user | Array of objects with ids |
| Dozens of fields, validation rules and dependent fields | A form library (React Hook Form, TanStack Form) or useReducer |
Key points
- One handler and
namekeys handle many fixed inputs. - Dynamic lists need an array of objects with stable ids, and
key={id}. - Use
type="button"for non-submit buttons inside forms.