Course outline · 0% complete

0/28 lessons0%

Course overview →

Objects and arrays in state

lesson 4-3 · ~15 min · 12/28

Objects and arrays in state

Real state is rarely a lone number, it is a list of tasks, a form object, a cart. Updating those wrongly is the single most common React bug in code review: the data changes but the screen does not. This lesson is about why that happens and the update patterns that prevent it.

React decides whether state changed by comparing the old and new values with Object.is, essentially ===. For numbers and strings that is simple. For objects and arrays, === compares identity, is it the same object in memory, not the contents.

So if you mutate an object and set it back, React sees the same object and may skip the re-render:

const [user, setUser] = useState({ name: "Amara", plan: "free" });

// WRONG: mutates the existing object, identity unchanged
user.plan = "pro";
setUser(user);

// RIGHT: build a NEW object with the change
setUser({ ...user, plan: "pro" });

The spread syntax { ...user, plan: "pro" } from Advanced JavaScript copies every field of user into a fresh object, then overrides plan. New object, new identity, guaranteed re-render.

Identity against contents

The first comparison is true because mutation keeps the same object, and the second is false because spread built a new one, which is what React needs.

const user = { name: "Amara", plan: "free" };

// WRONG for React: same object, mutated
const mutated = user;
mutated.plan = "pro";
console.log(user === mutated);

// RIGHT for React: new object via spread
const upgraded = { ...user, plan: "premium" };
console.log(user === upgraded);
console.log(upgraded.name + " is on " + upgraded.plan);

Output

true
false
Amara is on premium

user === mutated is true because both names point at one object in memory. The assignment const mutated = user copied a reference and not the data, so there is only ever one object here and user.plan now reads "pro" as well.

Spread copies fields into a brand-new object, so === is false and React re-renders. The third line confirms the copy carried name across, since spread takes every field and the override replaces only the one you name.

Note what the const does and does not prevent. const mutated stops the name from being reassigned, and it does nothing about changing fields inside the object, which is why mutated.plan = "pro" is legal on a const.

Spread is shallow: nested objects need a spread per level

One trap remains. Spread copies only the top level of an object. Field values that are themselves objects are copied by reference, the new object and the old one share them. So spreading once and then mutating a nested object still mutates shared data:

const [user, setUser] = useState({
  name: "Amara",
  address: { city: "Lagos", zip: "100001" },
});

// WRONG: copies user, but address is still the SAME shared object
const next = { ...user };
next.address.city = "Accra";   // also changes user.address.city!

// RIGHT: a new object at every level you change
setUser({ ...user, address: { ...user.address, city: "Accra" } });

The rule: to change something nested, spread every object on the path from the root to the field, and change only the field. Deeply nested state gets tedious, which is one reason experienced React developers keep state shallow.

Spreading at two levels

Building a moved user without mutating anything needs a spread at both levels, and the last two lines prove the root object and the nested address are both new.

const user = {
  name: "Amara",
  address: { city: "Lagos", zip: "100001" },
};

const moved = { ...user, address: { ...user.address, city: "Accra" } };

console.log(moved.name + " now in " + moved.address.city);
console.log("original: " + user.address.city);
console.log(user === moved);
console.log(user.address === moved.address);

Output

Amara now in Accra
original: Lagos
false
false

The outer spread copies name and address, and then the address override replaces the shared reference with a fresh object built from { ...user.address, city: "Accra" }. Reading it inside out is the way to keep the nesting straight.

The second line is the real test, since user.address.city still reads Lagos. A shallow copy would have printed Accra there, because both objects would have shared one address, and the original state would have been corrupted without any assignment to user appearing anywhere.

The last line is the one people get wrong. If it printed true, only the top level was spread and both objects still share one address, which is the shallow-copy trap producing a bug React cannot see.

The zip field is worth noticing too, since it survived without being mentioned. The inner spread carried it over, which is why spreading beats writing the object out by hand, where a forgotten field silently disappears.

Arrays: no push, no splice

The same rule applies to arrays in state. Mutating methods like push, splice, and sort change the array in place, so React may not notice. Use the non-mutating tools from Advanced JavaScript instead:

You want toAvoidUse
add an itempush[...tasks, newTask]
remove an itemsplicetasks.filter(t => t.id !== id)
change an itemtasks[i] = xtasks.map(t => t.id === id ? {...t, done: true} : t)

Every update produces a new array, leaving the old one untouched.

Non-mutating array updates

Appending with spread and removing with filter, and the final line proves the original array survived untouched.

const tasks = ["water plants", "pay rent"];

const added = [...tasks, "call mom"];
console.log(added.join(" | "));

const removed = added.filter(t => t !== "pay rent");
console.log(removed.join(" | "));

console.log(tasks.join(" | "));

Output

water plants | pay rent | call mom
water plants | call mom
water plants | pay rent

[...tasks, "call mom"] spreads the existing items into a new array and appends one, which is the array equivalent of the object spread from earlier. Putting the new item first, as ["call mom", ...tasks], prepends instead, and both produce a new array.

filter returns a new array containing the elements whose callback returned true, so removal is expressed as keeping everything else. That framing takes getting used to and it is why there is no remove method to reach for.

The third line is the proof that matters. tasks still holds both original items, so nothing in this sequence mutated it, and in React that means the previous state object is intact and comparable against the new one.

Contrast that with tasks.push("call mom"), which returns the new length rather than the array. Assigning that return value into state is a separate bug on top of the mutation, since state would become the number 3.

The most common React state bug

const [items, setItems] = useState([]);

function addItem(item) {
  items.push(item);
  setItems(items);
}

The bug is that push mutates the array in place, so setItems receives the same array identity and React may skip the re-render. The fix is setItems([...items, item]).

React compares old and new state with ===, so after push the old and new values are the same array object and the comparison says nothing changed. The data is correct and the screen is stale, which is the exact desync from lesson 1-1 arriving through a different door.

What makes this bug expensive is that it sometimes appears to work. Another state change elsewhere can trigger a re-render that happens to show the pushed item, so the bug looks intermittent and gets blamed on React.

The same mistake has three common spellings, all fixed the same way:

MutatingNon-mutating replacement
items.push(item)setItems([...items, item])
items.splice(i, 1)setItems(items.filter((x, n) => n !== i))
items.sort()setItems([...items].sort())

Note the last row, since sort mutates and returns the same array, so sorting state requires copying first. That one catches experienced developers, because sort looks like a query and is not.