Updating objects and arrays
In short: An object or array in React state is updated with a new copy (spread, map, filter), because React compares references and does not see a change to the old one.
Problem
"Remove" does not remove
The cart has three lines, each with a "Remove" button. Press "Remove" on the bread.
Console
The console says lines: 2. The screen shows all three lines and the old total. The data changed, the screen does not follow it, and there is no error.
remove takes the line out with splice and calls setCart(cart). The new value was passed. Why does React not draw?The idea
How the library sees a change
With a number it is clear: 1 and 2 are different. An array and an object, though, are references: the variable points to an object in memory. There are two questions. Does the variable point to another object? And did the contents of the same object change? Each library asks in its own way, and here the difference shows in the code.
React asks only the first question. On setCart(next) it compares the old and the new state with Object.is. For an array that means "is it the same object", not "does it have the same contents".
splice changes the contents of the same array. setCart(cart) passes the same reference, Object.is says "the same", and React skips the render. That is why in React the array and the objects in state are not changed in place. You make a new array, and React sees a new reference. This is called an immutable update.
See it
See: the same array or a new one
const [cart, setCart] = useState([bread]);
function addMilk() { cart.push(milk); setCart(cart);}
function addCheese() { setCart([...cart, cheese]);}Step by step, in words
- cart points to an array with one line. React keeps this reference as the component’s state, and the screen shows the bread.
- cart.push(milk) adds a line to the same array. The contents changed, but it is the same object.
- setCart(cart) passes the same reference. React compares the old and the new one with Object.is: the same object.
- The same means “nothing new”: React skips the render. The milk is in the array, but not on the screen.
- [...cart, cheese] makes a new array: a copy of the lines plus the cheese.
- setCart with the new array: Object.is says “different”, and React draws again.
- Now the screen shows three lines. The milk too, which until now was hidden in the old array.
- The rule in React: do not change the state’s array, give it a new one.
push, but it appears on the screen only with the cheese. Code with a mutation does not simply fail. It hides a change that comes out later, on a completely different action.The idea
A new array, a new object
The cart has three actions: add a line, change the quantity of one line, remove a line.
// add: a new array with the old lines and the new onesetCart([...cart, { ...product, qty: 1 }]);
// change one line: a new array, the changed line is a new objectsetCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
// remove: a new array without the linesetCart(cart.filter((line) => line.id !== id));[...cart, x]is a new array: the old elements andxat the end.{ ...line, qty: 5 }is a new object: the fields ofline, withqtychanged.mapandfilteralways return a new array. That is why they are the main tools here.
Try it
Fix "Remove"
This is the cart from the start. Fix remove so that the line disappears and the total is computed again. The check removes the bread, then the cheese.
Console
Solution
App.jsximport { useState } from 'react';
const money = (x) => '€' + x.toFixed(2);
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'White bread', price: 1.2, qty: 2 },
{ id: 5, name: 'Fresh milk', price: 1.6, qty: 1 },
{ id: 7, name: 'White cheese', price: 7.9, qty: 1 },
]);
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>The cart</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Remove ${line.name}`}>
Remove
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
setCart(cart.filter((line) => line.id !== id)): filter returns a new array without the line, and React sees it. const next = cart is not a copy: next and cart point to the same array, and splice on next is splice on the state.Try it
Find the bug
"Remove" does not remove. Which line is to blame?
Pick the line with the bug.
Line 2: const next = cart does not copy the array, it gives the same array a second name. splice changes the state in place and setCart(next) passes the same reference. It should be setCart(cart.filter((line) => line.id !== id)).
The idea
One line of the array
The customer presses "+" on the bread. The qty of one object has to change, and the object is in an array.
cart.find(...).qty++ changes the object in place: it is the same object React keeps as the old state. The array is the same, so React does not draw. The number does not vanish: it reaches the screen on the next render for another reason, and the quantity "jumps".
Be careful with copies too. [...cart] is a new array, but the lines in it are the same objects. This is a shallow copy: only the first level is copied. copy[0].qty++ changes the line in the old state too. The changed line has to be a new object: { ...line, qty: line.qty + 1 }. The lines that do not change stay the same objects, and that is fine.
More precisely, if you are curious
Object.is. Like ===, with two differences: Object.is(NaN, NaN) is true, and Object.is(0, -0) is false. For arrays and objects both compare references.
Nested objects. Every level on the path to the change is new: { ...order, address: { ...order.address, city: 'Varna' } }. The levels to the side stay the same.
structuredClone(cart) makes a deep copy: the array and every object in it. It works, but it copies everything on every change, and it cannot copy functions. For one line map and ... are cheaper.
Immer. The Immer library lets you write as if mutating (draft[0].qty++), and it makes the new objects for you. It is not in the sandbox.
It works in Vue too. The new array is correct in Vue as well: cart.value = cart.value.filter(...). The other way round does not hold: the mutation, normal in Vue, is not seen in React.
Try it
"+" and "−"
Write more and less. "+" raises the quantity of the line by 1, and "−" lowers it by 1. When the quantity is 1, "−" removes the line. The total follows the quantity.
Console
Solution
App.jsximport { useState } from 'react';
const money = (x) => '€' + x.toFixed(2);
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'White bread', price: 1.2, qty: 2 },
{ id: 5, name: 'Fresh milk', price: 1.6, qty: 1 },
{ id: 7, name: 'White cheese', price: 7.9, qty: 1 },
]);
function more(id) {
setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
}
function less(id) {
const line = cart.find((l) => l.id === id);
if (line.qty === 1) {
remove(id);
} else {
setCart(cart.map((l) => (l.id === id ? { ...l, qty: l.qty - 1 } : l)));
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>The cart</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => less(line.id)} aria-label={`Fewer ${line.name}`}>−</button>
<button type="button" onClick={() => more(line.id)} aria-label={`More ${line.name}`}>+</button>
<button type="button" onClick={() => remove(line.id)} aria-label={`Remove ${line.name}`}>
Remove
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
more is setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))). less finds the line, and when qty === 1 it calls remove, otherwise it does the same map with - 1. If you write line.qty++ and setCart(cart), React does not draw: the array and the line are the same. Without the check for 1 the line reaches "0 ×" and stays in the cart.
Exercise
Catalogue and cart
The catalogue has four products with an "Add" button (the name for the screen reader: "Add White bread"). Write add and remove. The first click on "Add" makes a line with quantity 1, and the next ones raise the quantity of the same line instead of making a new one. "Remove" removes the line. When the cart is empty, it says "The cart is empty".
Console
Solution
App.jsximport { useState } from 'react';
const money = (x) => '€' + x.toFixed(2);
const products = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 10, name: 'Apples', price: 2.1 },
];
export default function App() {
const [cart, setCart] = useState([]);
function add(product) {
const found = cart.find((line) => line.id === product.id);
if (found) {
setCart(cart.map((line) => (line.id === product.id ? { ...line, qty: line.qty + 1 } : line)));
} else {
setCart([...cart, { ...product, qty: 1 }]);
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h1>The shop</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}, {money(p.price)}
<button type="button" onClick={() => add(p)} aria-label={`Add ${p.name}`}>
Add
</button>
</li>
))}
</ul>
<h2>The cart</h2>
{cart.length === 0 && <p>The cart is empty</p>}
<ul aria-label="The cart">
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Remove ${line.name}`}>
Remove
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
add looks for the line with find. If it is there: map with a new object for it. If not: setCart([...cart, { ...product, qty: 1 }]). { ...product, qty: 1 } is a new object, and the product in the catalogue is not touched. remove is filter. With cart.push and setCart(cart) the cart stays empty, and with [...cart, …] every click adds a new line for the same product and two lines share one key.Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The first component · Lists and keys · Conditions: empty, error, loading · State
- Continues in: Derived state · Lifting state up · Shared state · The shop front
- Tasks on this topic: State