State can hold objects and arrays, and real apps need that all the time: a user profile, a list of tasks, a shopping cart. But there is a rule that surprises beginners: do not change state objects and arrays in place. Instead, make a new object or array with the change, and give that to the setter. This lesson shows the patterns.
Why you must not mutate
React decides whether to re-render by comparing the new state with the old using Object.is. For objects and arrays that means same object in memory, not "same contents". If you change an array in place and pass the same array back, React sees the same object and concludes nothing changed:
import { useState } from "react";
function App() {
const [items, setItems] = useState(["Milk"]);
function addBad() {
items.push("Eggs"); // mutates the existing array
setItems(items); // same array object: React skips the render
console.log("array now has", items.length, "items");
}
return (
<div>
<button className="bad" onClick={addBad}>Add (mutating)</button>
<p>On screen: {items.join(", ")}</p>
</div>
);
}
export default App;Add (mutating)
On screen: Milk
CONSOLE
array now has 2 items
array now has 3 itemsThe data changed (the log says 3 items after two clicks), yet the screen still shows only "Milk". Worse, the mutation can show up later, when some unrelated state change triggers a render, which makes the bug hard to trace. Mutation also breaks features that rely on the old value staying intact. Now the correct version:
import { useState } from "react";
function App() {
const [items, setItems] = useState(["Milk"]);
function addGood() {
setItems([...items, "Eggs"]); // a new array: the old items plus one more
}
return (
<div>
<button className="good" onClick={addGood}>Add (copying)</button>
<p>On screen: {items.join(", ")}</p>
</div>
);
}
export default App;Add (copying)
On screen: Milk, Eggs, EggsTwo clicks, two "Eggs". The rule to remember: state is read-only; replace it.
Updating objects
For an object, copy its properties with the spread operator and override what changes:
import { useState } from "react";
function App() {
const [user, setUser] = useState({ name: "Asha", age: 30, city: "Pune" });
return (
<div>
<p>{user.name}, {user.age}, from {user.city}</p>
<button className="city" onClick={() => setUser({ ...user, city: "Delhi" })}>Move to Delhi</button>
<button className="birthday" onClick={() => setUser(u => ({ ...u, age: u.age + 1 }))}>Birthday</button>
</div>
);
}
export default App;Asha, 31, from Delhi
Move to DelhiBirthday{ ...user, city: "Delhi" } reads as "everything in user, but with city replaced". Properties written after the spread override the copied ones. The second button uses an updater function (note the round brackets around the object literal, so that the arrow function returns it).
Nested objects
Spread makes a shallow copy: nested objects are still shared with the old one. To change something deep inside, copy every level on the path:
import { useState } from "react";
function App() {
const [person, setPerson] = useState({
name: "Asha",
address: { city: "Pune", pin: "411001" },
});
function moveToDelhi() {
setPerson({
...person, // copy the person
address: { ...person.address, city: "Delhi" }, // copy the address, change the city
});
}
return (
<div>
<p>{person.name} lives in {person.address.city} ({person.address.pin})</p>
<button onClick={moveToDelhi}>Move</button>
</div>
);
}
export default App;Asha lives in Delhi (411001)
MoveThe pin code survived because ...person.address copied it. If the nesting gets deep, the repetition becomes tiresome, which is one more reason to keep state flat (the next lesson) or to use the small library Immer (use-immer), which lets you write "mutating" code that it turns into copies. Learn the plain way first.
Updating arrays
Array methods fall into two groups: those that mutate (avoid them on state) and those that return a new array (use these):
| Task | Avoid (mutates) | Use (returns a new array) |
|---|---|---|
| add at the end | push |
[...items, item] |
| add at the start | unshift |
[item, ...items] |
| remove | splice, pop, shift |
filter |
| replace or change | items[i] = x |
map |
| sort | sort |
[...items].sort(...) or toSorted |
| reverse | reverse |
[...items].reverse() or toReversed |
| insert in the middle | splice |
[...items.slice(0, i), item, ...items.slice(i)] |
A complete example with add, remove and toggle on a list of objects:
import { useState } from "react";
let nextId = 3;
function App() {
const [tasks, setTasks] = useState([
{ id: 1, text: "Learn state", done: false },
{ id: 2, text: "Write code", done: false },
]);
const [text, setText] = useState("");
function addTask() {
if (text.trim() === "") return;
setTasks([...tasks, { id: nextId++, text: text.trim(), done: false }]);
setText("");
}
function toggleTask(id) {
setTasks(tasks.map(task => (task.id === id ? { ...task, done: !task.done } : task)));
}
function removeTask(id) {
setTasks(tasks.filter(task => task.id !== id));
}
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} placeholder="New task" />
<button className="add" onClick={addTask}>Add</button>
<ul>
{tasks.map(task => (
<li key={task.id}>
<input type="checkbox" checked={task.done} onChange={() => toggleTask(task.id)} />{" "}
<span style={{ textDecoration: task.done ? "line-through" : "none" }}>{task.text}</span>{" "}
<button className="remove" onClick={() => removeTask(task.id)}>x</button>
</li>
))}
</ul>
</div>
);
}
export default App;Add
Learn state x
Buy milk x
Call Ravi xIn the test I added two tasks, ticked the first one and removed the second. Study the three update functions:
- Add:
[...tasks, newTask]copies the old list and appends the new object. The id comes from a counter kept outside the component, so it does not change when the component re-renders (in a real app you would usecrypto.randomUUID()or the id your server returns). - Toggle:
mapgoes through the list and returns the same task for most items, and a new object ({ ...task, done: !task.done }) for the one that changed. Items that did not change are reused, which is cheap. - Remove:
filterkeeps every task whose id does not match.
The input uses state too (text with value and onChange); that is a controlled input, the topic of the Forms lessons.
Inserting and reordering
import { useState } from "react";
function App() {
const [letters, setLetters] = useState(["d", "a", "c"]);
function insertB() {
setLetters([...letters.slice(0, 1), "b", ...letters.slice(1)]); // after the first item
}
return (
<div>
<p>{letters.join(" ")}</p>
<button className="insert" onClick={insertB}>Insert b after first</button>
<button className="sort" onClick={() => setLetters([...letters].sort())}>Sort</button>
<button className="reverse" onClick={() => setLetters([...letters].reverse())}>Reverse</button>
</div>
);
}
export default App;d c b a
Insert b after firstSortReverseStart d a c; insert gives d b a c; sort gives a b c d; reverse gives d c b a. The copies [...letters] are what keep each step safe: calling letters.sort() directly would sort the state array in place.
Why immutability is worth it
Beyond making React notice changes, never mutating state brings real benefits: past states stay intact (you can implement undo by keeping old arrays), React can skip work by comparing identities, components that receive an object as a prop can tell cheaply whether it changed, and bugs where two places accidentally share and change the same array disappear.
Note that this applies to values in state. You may build and mutate a new array during rendering (a const result = [] that you push into and return); the earlier lesson on pure components explains that creating something inside the render is fine.
Common mistakes
- Using
push,splice,sortorreversedirectly on a state array. - Assigning to a property of a state object:
user.city = "Delhi"; setUser(user). - Forgetting that spread is shallow, and mutating a nested object.
- Forgetting the brackets around an object literal returned from an arrow function:
u => { ...u }is a syntax error; writeu => ({ ...u }). - Using the array index as the identity for items you add, remove or reorder; use a stable id.
- Creating the id inside the component with a plain counter variable, which resets every render. Use a counter outside the component, state, or a UUID.
Exercise
- Add an "Edit" feature to the task list: clicking a button next to a task changes its text to upper case, using
map. - Add a "Clear done" button that removes all completed tasks with one
filter. - Add a "Move to top" button on each task using
filterand a spread. - Build a profile with a nested
address, and add a button to change only the pin code without losing the city.
Key points
- Treat state as read-only: to change an object or array, build a new one and pass it to the setter.
- For objects use spread ({ ...user, city: 'Delhi' }); for nested objects, copy each level you change.
- For arrays, add with [...items, item], remove with filter, change with map, and sort or reverse a copy.
- React skips the update if you pass the same object back, so a mutated object with the same identity never shows on screen.