Topic 5.5
Many Fields with One State Object
In one line
Keep a form's values in one state object, give every field a name that matches a key, and update any field with one change handler using a computed property key: { ...form, [e.target.name]: e.target.value }.
Think of it like this
A school register. The teacher doesn't keep a separate notebook per pupil; there is one register with a row per name. To mark Asha present, the teacher finds the row labelled Asha and writes there. One state object for a form is that register: one object, one key per field, and the field's name tells you which row to write in.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- State object
- One state value that holds several related fields:
{ name: "", email: "" }. - name attribute
- The field's name. Here it matches the key in the state object, so one handler knows what to update.
- Computed property key
- A key written as
[expression]in an object literal. JavaScript works the key out at run time. - Shared change handler
- One
onChangefunction used by many fields, which readse.target.nameto know which field changed. - Nested form data
- Form values grouped inside objects, such as
address.city. - Dynamic field list
- A part of the form where the user can add and remove rows of fields.
- Stable id
- An identifier made once when an item is created, that never changes, used as its key.
Step by step
01One object for the whole form
List every field once, with its starting value. This object is also the shape you'll send to the server.
const [form, setForm] = useState({
name: "",
email: "",
role: "student",
newsletter: false,
});02Name every field after its key
name and the key must match exactly, including capital letters.
<input name="name" value={form.name} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} />
<select name="role" value={form.role} onChange={handleChange}>…</select>
<input type="checkbox" name="newsletter" checked={form.newsletter} onChange={handleChange} />03Write one handler
Pick checked or value by type, then copy the object with one key replaced.
function handleChange(e) {
const { name, value, type, checked } = e.target;
setForm((f) => ({ ...f, [name]: type === "checkbox" ? checked : value }));
}04Nested values need a copy per level
A dotted name tells the handler which group the field belongs to.
function handleAddress(e) {
const key = e.target.name.split(".")[1]; // "address.city" -> "city"
setForm((f) => ({ ...f, address: { ...f.address, [key]: e.target.value } }));
}05Rows you can add and remove
Each row is an object with its own id. Change and remove by id; key the inputs by id.
let nextId = 1;
const [guests, setGuests] = useState([]);
function add() {
const id = nextId++;
setGuests((gs) => [...gs, { id, name: "" }]);
}
const rename = (id, name) => setGuests((gs) => gs.map((g) => (g.id === id ? { ...g, name } : g)));
const remove = (id) => setGuests((gs) => gs.filter((g) => g.id !== id));Try it yourself
- 1
Add a field in two lines
In the profile form, add
phone: ""toinitialand<input name="phone" value={form.phone} onChange={handleChange} />. No new handler is needed. - 2
Misspell a name
Change the email input's
nametoEmail(capital E) and type in it. Predict what appears in the JSON. (A new keyEmailappears andemailstays empty, while the field itself stays empty because it showsform.email.) - 3
Key the guests by index
Change
key={g.id}tokey={i}. Type different names in three rows, put the cursor in row 3, and remove row 1. The text still follows the data, but focus and any uncontrolled state stay on the wrong positions. Putg.idback.
Code & diagrams
Six fields, two handlers. The JSON under the form is the state, live. Type in any field and watch exactly one key change.
Preview
Console
Nothing logged yet.Expected console output
{ name: 'Asha', email: '', role: 'student', newsletter: true, address: { city: 'Pune', pin: '' } }Two rows were added from code. Type a name into each row, then remove the first one: the other rows keep their own text, because they are keyed by id, not by position.
Preview
Console
Nothing logged yet.Expected console output
rows: 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the brackets around the key
In the profile form, change [name]: ... to name: ... in handleChange, then type Asha in the Email field.
Break #2
Forget to spread the old values
In a form with flat fields { name: "", email: "" }, write the handler as setForm({ [name]: value }) without the spread, then type one letter in Name.
Myth vs fact
Myth
One state object is always better than many useState calls.
Fact
One object suits fields that are edited and sent together. Unrelated values (a modal's open flag, a search box) are clearer in their own state.
Myth
Setting state merges the new keys into the old object, like class components did.
Fact
useState setters replace the value. You must spread the old object yourself.
Myth
The array index is fine as a key for form rows.
Fact
Rows that can be removed or reordered need stable ids; otherwise focus, uncontrolled values and component state stick to the wrong row (Topic 3.3).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
With one object, every keystroke re-renders the whole form component and all its fields. For forms with dozens of fields that can be noticeable; split sections into child components, wrap them in
memo(Phase 16), or use an uncontrolled approach such as React Hook Form (Topic 5.7). The React Compiler can remove much of this cost automatically. - ▸
Reading
e.targetinside the updater function is safe in React 17+ (no more event pooling), but destructuringname/valuefirst is clearer and survives later refactors that make the handler async. - ▸
Module-level
let nextIdis fine for a demo but shared between all instances and reset on reload. Real apps usecrypto.randomUUID()when the row is created, or ids from the server. - ▸
useReducer(Topic 9.4) is a good fit for large forms: actions likefield_changed,row_addedandresetkeep the update rules in one tested function.
Remember this
- 1
With ten fields, ten
useStatecalls and ten handlers are repetitive. Instead keep one object:useState({ name: "", email: "", city: "" }). Each field shows its own key,value={form.email}, and carries anameattribute with the same text:name="email". - 2
Now one handler can serve every field.
e.target.namesays which field changed ande.target.valuewhat it now holds:setForm({ ...form, [e.target.name]: e.target.value }). The spread...formcopies all the old keys (state must be replaced, not changed, Topic 4.6), and the new key overwrites one of them. - 3
[e.target.name]in an object literal is a computed property key: JavaScript works out the key from the expression in the brackets.{ ["em" + "ail"]: 1 }is{ email: 1 }. Without the brackets,{ name: value }would always set a key literally calledname. - 4
Checkboxes report
checked, notvalue, so a shared handler checks the type:const v = type === "checkbox" ? checked : value;. Using the updater form,setForm((f) => ({ ...f, [name]: v })), keeps updates correct even when several happen in one event (Topic 4.5). Readname,valueandcheckedfrome.targetbefore the updater runs. - 5
Nested form data such as
address: { city, pin }needs a copy at each level:{ ...form, address: { ...form.address, [name]: value } }. A common trick is a dotted name,name="address.city", which the handler splits. Deep nesting gets awkward fast; keep form state as flat as you can (Topic 6.1), or use Immer (Topic 4.6). - 6
Dynamic field lists (several phone numbers, guests, line items) are an array in state. Give each row a stable
idmade when the row is added (not the array index, Topic 3.3), and update rows by id: add with[...rows, { id, name: "" }], change withrows.map((r) => r.id === id ? { ...r, name } : r), remove withrows.filter((r) => r.id !== id). With index keys, removing a row makes the inputs below it show the wrong text or keep the wrong focus.
Explain it without notes
How does one change handler update the right field in a state object?
What is a computed property key, and why does the form handler need it?
How do you update a nested field like address.city without mutating state?
How should you manage a list of rows that users can add and remove?
Practice
Build a booking form with name, date and seats in one state object and one handler, showing Asha booked 2 seats on 2024-01-15 below it.
Build a list of email inputs with Add and Remove buttons, stored as [{ id, email }].
Trade-offs
- ↔
One object and one handler cut repetition and match the shape you send to the server, but every change re-renders the whole form and a typo in a
namefails silently. - ↔
Nesting mirrors the server's data shape but makes updates verbose; flat state with names like
addressCityis simpler to update and to validate.
Done when you can
I can manage many fields with one state object and one handler.
I can explain computed property keys.
I handle checkboxes in a shared handler.
I can update nested form data immutably.
I can build add/edit/remove rows keyed by stable ids.