Updating Objects & Arrays
State often holds more than a single number: a profile object with several fields, or a list of tasks. These values need extra care, because the obvious way to change them in JavaScript (user.city = "Pune" or items.push(x)) does not work with React.
In this lesson you will learn why you must never change state objects and arrays in place, and how to make updated copies with spread syntax and array methods for every common case: add, remove, edit, insert and sort.
Why mutation does not work
Mutating means changing an existing object or array in place. When you call a setter, React checks whether the new value is the same object as the old one, using Object.is. If you change the object in place and pass it back, it is still the same object, so React decides nothing changed and skips the re-render.
import { useState } from "react";
import { createRoot } from "react-dom/client";
function Profile() {
const [user, setUser] = useState({ name: "Ananya", city: "Kochi" });
function moveWrong() {
user.city = "Pune"; // mutation
setUser(user); // same object: React skips the update
console.log("Wrong way, same object?", Object.is(user, user));
}
function moveRight() {
const updated = { ...user, city: "Pune" }; // new object
console.log("Right way, same object?", Object.is(updated, user));
setUser(updated);
}
return (
<div>
<p>{user.name} lives in {user.city}</p>
<button onClick={moveWrong}>Move (mutate)</button>{" "}
<button onClick={moveRight}>Move (copy)</button>
</div>
);
}
createRoot(document.getElementById("root")).render(<Profile />);Clicking "Move (mutate)" and then "Move (copy)" prints:
Wrong way, same object? true Right way, same object? false
After the first click the screen still says Kochi, even though the object secretly says Pune. The second click creates a new object, so React re-renders. Mutation also breaks features you will meet later, such as memo and the React Compiler, which rely on "new object means changed". So treat every object and array in state as read-only.
Updating objects with spread
The spread syntax ... copies all properties of an object into a new one. Properties written after the spread override the copied ones. This is perfect for forms with many fields, where one handler can update any field by its name:
import { useState } from "react";
import { createRoot } from "react-dom/client";
function Admission() {
const [form, setForm] = useState({
name: "Rohan Mehta",
course: "BCA",
address: { city: "Rohtak", pin: "124001" },
});
function handleChange(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function handleCityChange(e) {
setForm({ ...form, address: { ...form.address, city: e.target.value } });
}
return (
<div>
<input name="name" value={form.name} onChange={handleChange} />
<input name="course" value={form.course} onChange={handleChange} />
<input value={form.address.city} onChange={handleCityChange} />
<p>
{form.name} applied for {form.course} from {form.address.city} ({form.address.pin})
</p>
</div>
);
}
createRoot(document.getElementById("root")).render(<Admission />);Two techniques appear here. [e.target.name]: e.target.value is a computed property name: the input's name attribute decides which field changes. And for the nested address object, you must copy each level you change: a new outer object and a new address object. Writing { ...form, city: "Pune" } would add a wrong top-level city field instead.
Deeply nested state gets verbose. If you find yourself spreading four levels deep, consider flattening the state shape, or use the small Immer library (useImmer), which lets you write mutating-looking code that produces copies for you.
Updating arrays
Arrays follow the same rule: produce a new array. Some array methods mutate, others return a new array. Prefer the ones on the right:
| Task | Avoid (mutates) | Prefer (returns a new array) |
|---|---|---|
| Add | push, unshift | [...arr, item], [item, ...arr] |
| Remove | splice, pop, shift | filter |
| Replace or edit | arr[i] = x | map, with(i, x) |
| Insert in the middle | splice | toSpliced, or spread with slice |
| Sort or reverse | sort, reverse | toSorted, toReversed |
Here is a shopping list that adds, removes, toggles and sorts items without a single mutation:
import { useState } from "react";
import { createRoot } from "react-dom/client";
let nextId = 4;
function ShoppingList() {
const [items, setItems] = useState([
{ id: 1, name: "Basmati rice 5 kg", done: false },
{ id: 2, name: "Toor dal", done: true },
{ id: 3, name: "Amul butter", done: false },
]);
const [text, setText] = useState("");
function add() {
if (!text.trim()) return;
setItems([...items, { id: nextId++, name: text.trim(), done: false }]);
setText("");
}
function toggle(id) {
setItems(items.map((it) => (it.id === id ? { ...it, done: !it.done } : it)));
}
function remove(id) {
setItems(items.filter((it) => it.id !== id));
}
function sortByName() {
setItems(items.toSorted((a, b) => a.name.localeCompare(b.name)));
}
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add an item" />
<button onClick={add}>Add</button> <button onClick={sortByName}>Sort A to Z</button>
<ul>
{items.map((it) => (
<li key={it.id}>
<label style={{ textDecoration: it.done ? "line-through" : "none" }}>
<input type="checkbox" checked={it.done} onChange={() => toggle(it.id)} /> {it.name}
</label>{" "}
<button onClick={() => remove(it.id)}>Remove</button>
</li>
))}
</ul>
<p>{items.filter((it) => !it.done).length} items left to buy</p>
</div>
);
}
createRoot(document.getElementById("root")).render(<ShoppingList />);Look closely at toggle. map builds a new array; for the matching item it returns a new object with done flipped, and every other item is returned unchanged. Editing it.done = !it.done inside map would mutate the original object, which is still shared with the old state.
Inserting and the copy trap
To insert an item at a position, toSpliced (ES2023) returns a new array and leaves the original alone:
// insert "Jeera" at index 1
setItems(items.toSpliced(1, 0, { id: nextId++, name: "Jeera", done: false }));A common trap is the shallow copy. [...items] makes a new array, but the objects inside are the same objects as before. Copying the array and then editing an object inside it still mutates state:
const copy = [...items];
copy[0].done = true; // still mutates the original object!
setItems(copy);Always create a new object for the item you change, as map with { ...it, done: true } does. For a full deep copy of plain data, structuredClone(value) exists, but copying only what changes is faster and clearer.
Common mistakes
pushthen set.items.push(x); setItems(items);passes the same array, so nothing re-renders. UsesetItems([...items, x]).- Editing inside
map. Return a new object for the changed item instead of assigning to it. - Forgetting nested levels. Each level you change needs its own spread.
- Sorting state in place.
items.sort()mutates state; usetoSorted(). - Stale updates in quick succession. When an update depends on the previous array, an updater works here too:
setItems((prev) => [...prev, x]).
Key points
- Never mutate objects or arrays in state; create new ones and pass them to the setter.
- React compares the old and new value with
Object.is; the same object means "no change". - Copy objects with
{ ...obj, field: value }, and spread every nested level you change. - Add with spread, remove with
filter, edit withmap, insert withtoSpliced, sort withtoSorted. [...arr]is a shallow copy: objects inside are still shared.
Exercise
Create a Marks component whose state is an array of { id, subject, marks } for three subjects. Add a "+5 grace" button next to each subject that adds 5 marks (maximum 100) to that subject only, a "Remove" button, and a "Sort by marks" button that sorts from highest to lowest. Show the average below the list.
Show solution
import { useState } from "react";
import { createRoot } from "react-dom/client";
function Marks() {
const [rows, setRows] = useState([
{ id: 1, subject: "Maths", marks: 72 },
{ id: 2, subject: "Physics", marks: 97 },
{ id: 3, subject: "English", marks: 64 },
]);
function grace(id) {
setRows(rows.map((r) => (r.id === id ? { ...r, marks: Math.min(100, r.marks + 5) } : r)));
}
const average = rows.length ? Math.round(rows.reduce((s, r) => s + r.marks, 0) / rows.length) : 0;
return (
<div>
<button onClick={() => setRows(rows.toSorted((a, b) => b.marks - a.marks))}>Sort by marks</button>
<ul>
{rows.map((r) => (
<li key={r.id}>
{r.subject}: {r.marks} <button onClick={() => grace(r.id)}>+5 grace</button>{" "}
<button onClick={() => setRows(rows.filter((x) => x.id !== r.id))}>Remove</button>
</li>
))}
</ul>
<p>Average: {average}</p>
</div>
);
}
createRoot(document.getElementById("root")).render(<Marks />);map returns a new object only for the chosen subject, filter removes without mutating, and toSorted sorts a copy. The average is derived during render.
Quick Quiz
1. Why does user.city = "Pune"; setUser(user); not update the screen?
React compares with Object.is; a mutated object is still the same object.
2. Which line correctly adds an item to an array in state?
Spread creates a new array that contains the old items plus the new one.
3. How do you change pin inside form.address?
Every nested level you change must be copied with its own spread.
4. Which method removes an item without mutating the array?
filter returns a new array containing only the items you keep.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
