State and Events · Lesson 10 of 31
Updating Objects and Arrays in State
State can hold objects and arrays – a form, a cart, a list of todos. The one rule: never change them in place. Create a new object or array with the change and pass that to the setter. This lesson shows why, and gives you the patterns for every common update.
In this lesson
- Why mutating state doesn't re-render
- Updating objects with spread, including nested objects
- Adding, removing and editing array items
- A complete todo list
Mutation doesn't work
import { useState } from "react";
export default function App() {
const [user, setUser] = useState({ name: "Asha", points: 10 });
function addPointsWrong() {
user.points += 5; // ✗ mutates the existing object
setUser(user); // same object → React sees no change and skips the render
}
function addPointsRight() {
setUser({ ...user, points: user.points + 5 }); // ✓ new object
}
return (
<div>
<p>{user.name}: {user.points} points</p>
<button onClick={addPointsWrong}>Wrong +5</button>
<button onClick={addPointsRight}>Right +5</button>
</div>
);
}
Asha: 10 points [Wrong +5] [Right +5] › click “Wrong +5” Asha: 10 points [Wrong +5] [Right +5] › click “Wrong +5” Asha: 10 points [Wrong +5] [Right +5] › click “Right +5” Asha: 25 points [Wrong +5] [Right +5]
React compares the old and new state with Object.is. Passing the same object means "nothing changed", so the two wrong clicks show nothing – but they really did mutate the object, which is why the right click jumps straight to 25. Mutation creates exactly this kind of confusing, out-of-sync UI.
Updating objects
import { useState } from "react";
export default function App() {
const [profile, setProfile] = useState({
name: "Asha",
email: "asha@example.com",
address: { city: "Pune", pin: "411001" },
});
function handleChange(e) {
const { name, value } = e.target;
setProfile((p) => ({ ...p, [name]: value })); // one handler for every top-level field
}
function moveToDelhi() {
setProfile((p) => ({ ...p, address: { ...p.address, city: "Delhi", pin: "110001" } })); // copy each level
}
return (
<div>
<input name="name" aria-label="Name" value={profile.name} onChange={handleChange} />
<input name="email" aria-label="Email" value={profile.email} onChange={handleChange} />
<button onClick={moveToDelhi}>Move to Delhi</button>
<p>{profile.name} · {profile.email} · {profile.address.city} {profile.address.pin}</p>
</div>
);
}
[Asha] [asha@example.com] [Move to Delhi] Asha · asha@example.com · Pune 411001 › clear Name and type “Asha Verma” [Asha Verma] [asha@example.com] [Move to Delhi] Asha Verma · asha@example.com · Pune 411001 › click “Move to Delhi” [Asha Verma] [asha@example.com] [Move to Delhi] Asha Verma · asha@example.com · Delhi 110001
The spread { ...p } copies only one level. For nested data, copy every level on the path to the change. If your state is deeply nested, consider flattening it – or use a helper library like Immer, which lets you write "mutating" code that produces a new object.
Updating arrays
| Goal | Avoid (mutates) | Use (returns a new array) |
|---|---|---|
| Add | push, unshift | [...arr, item], [item, ...arr] |
| Remove | splice, pop | arr.filter(x => x.id !== id) |
| Replace / edit | arr[i] = x | arr.map(x => x.id === id ? { ...x, done: true } : x) |
| Sort / reverse | sort, reverse | toSorted, toReversed |
| Insert at index | splice | toSpliced(i, 0, item) or slices |
A complete todo list
import { useState } from "react";
let nextId = 3;
export default function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn useState", done: true },
{ id: 2, text: "Build a todo app", done: false },
]);
const [text, setText] = useState("");
function addTodo(e) {
e.preventDefault();
if (!text.trim()) return;
setTodos([...todos, { id: nextId++, text: text.trim(), done: false }]); // add
setText("");
}
const toggle = (id) => setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t))); // edit
const remove = (id) => setTodos(todos.filter((t) => t.id !== id)); // remove
const left = todos.filter((t) => !t.done).length; // derived value: compute it, don't store it
return (
<div>
<form onSubmit={addTodo}>
<input aria-label="New todo" value={text} onChange={(e) => setText(e.target.value)} />
<button>Add</button>
</form>
<ul>
{todos.map((t) => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} /> {t.text}
</label>
<button onClick={() => remove(t.id)}>Delete {t.text}</button>
</li>
))}
</ul>
<p>{left} left</p>
</div>
);
}
[] [Add] • [x] Learn useState [Delete Learn useState] • [ ] Build a todo app [Delete Build a todo app] 1 left › type “Write tests” into New todo [Write tests] [Add] • [x] Learn useState [Delete Learn useState] • [ ] Build a todo app [Delete Build a todo app] 1 left › click “Add” [] [Add] • [x] Learn useState [Delete Learn useState] • [ ] Build a todo app [Delete Build a todo app] • [ ] Write tests [Delete Write tests] 2 left › click “Build a todo app” [] [Add] • [x] Learn useState [Delete Learn useState] • [x] Build a todo app [Delete Build a todo app] • [ ] Write tests [Delete Write tests] 1 left › click “Delete Learn useState” [] [Add] • [x] Build a todo app [Delete Build a todo app] • [ ] Write tests [Delete Write tests] 1 left
Don't store what you can compute
The "left" count is calculated from todos on each render instead of being kept in its own state. Duplicated state gets out of sync; derived values can't.
Practice
Try it yourself
A cart holds
[{ id, name, qty }]. Write an "increase" button per item and a "Clear cart" button.Show solution
import { useState } from "react"; export default function App() { const [cart, setCart] = useState([ { id: "pen", name: "Pen", qty: 1 }, { id: "book", name: "Notebook", qty: 2 }, ]); const increase = (id) => setCart((c) => c.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i))); return ( <div> <ul> {cart.map((i) => ( <li key={i.id}> {i.name} × {i.qty} <button onClick={() => increase(i.id)}>More {i.name}</button> </li> ))} </ul> <p>Items: {cart.reduce((n, i) => n + i.qty, 0)}</p> <button onClick={() => setCart([])}>Clear cart</button> </div> ); }• Pen × 1 [More Pen] • Notebook × 2 [More Notebook] Items: 3 [Clear cart] › click “More Pen” • Pen × 2 [More Pen] • Notebook × 2 [More Notebook] Items: 4 [Clear cart] › click “Clear cart” Items: 0 [Clear cart]
Quick check
Why doesn't state.push(x); setState(state) re-render?
It's the same array reference, so React thinks nothing changed.
How do you change one item in an array?
map over it and return a new object for the matching item.
Does { ...obj } copy nested objects?
No – it's a shallow copy. Copy each nested level you change.
Summary
- Treat state as immutable: always pass a new object or array to the setter.
- Objects: spread and override; copy each nested level.
- Arrays: spread to add, filter to remove, map to edit, toSorted to sort.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.