A React component keeps a list in state. A handler runs `items.push(newItem); setItems(items);` and the rendered list never changes. What comparison is React making, why does `setItems([...items, newItem])` work instead, and can you rely on that comparison as a performance optimisation?
answer
- identity, never contents
- the same array is the same value
- push returns you nothing new
- spread buys a new reference
- skipping the render is not promised
basics
~20 sReact compares the next state with the current one using Object.is. Pushing mutates the same array, so the reference is identical and React bails out. A new array from the spread is a different reference, so the update is seen. Treat the bail-out as a courtesy, not a guarantee.
solid answer
~50 sReact compares what you pass to the setter against the current state with `Object.is` — reference identity for objects and arrays, with no structural inspection at all. `items.push(newItem)` mutates the array in place, so the thing you hand back is the same object React already holds; the comparison says "unchanged" and React skips the update, even though the contents differ. `setItems([...items, newItem])` allocates a new array, so `Object.is` reports a change and the component re-renders with the new value. That is the whole reason immutable updates are the rule in React, not a stylistic preference. As for relying on it: I do not. React documents that when the value is identical it can skip re-rendering the component and its children, but it may still call the component once before bailing out — so it is a legitimate optimisation for React to make, not a guarantee I build behaviour on.
code
jsx · 22 linesimport { useState } from 'react';
export default function List() {
const [items, setItems] = useState(['a']);
function addBroken() {
items.push('x'); // same reference
setItems(items); // Object.is says unchanged -> no update
}
function addWorking() {
setItems(prev => [...prev, 'x']); // new reference -> update lands
}
return (
<>
<ul>{items.map((i, idx) => <li key={idx}>{i}</li>)}</ul>
<button onClick={addBroken}>broken</button>
<button onClick={addWorking}>working</button>
</>
);
}go deeper
Know the rule and follow it: never push into or assign into something held in state; build a new array or object with the spread, map or filter and pass that to the setter.
Explain the mechanism — the setter compares with Object.is, so identity is all that matters — and write correct updates for nested shapes, including replacing one item inside a list.
Diagnose the silent symptom from a bug report, name the mutating methods that cause it, and state precisely what the bail-out does and does not promise, since React may still call the component once.
Make immutability structural rather than a habit: shape state so deep copying is unnecessary, enforce it with lint or development freezing, and keep architecture off any assumption that React will skip work for you.
## What React compares When you call the state setter, React compares the value you supplied with the value it currently holds using **`Object.is`** — the same comparison it uses for dependency arrays. For primitives this is intuitive: `setCount(5)` when count is already 5 compares equal. For objects, arrays, Maps, Sets and functions it is **reference identity**: two arrays with identical contents are not equal, and one array compared with itself always is. React never walks the contents. There is no deep comparison anywhere in the state path, at any depth, for any value. ## Why mutation is invisible ```jsx function addWrong(newItem) { items.push(newItem); // same array object, now longer setItems(items); // Object.is(items, items) === true } ``` You did change the data, and if you logged `items` you would see the new element. But the only question React asks is "is this the same value I already have?", and the answer is yes. It concludes there is nothing to do. The UI keeps showing what it last rendered, and the divergence is silent — no warning, no error, just a list that stops growing. Worse, the mutation has already corrupted the value React holds, so a later unrelated update can suddenly reveal several accumulated changes at once, which is why these bugs get reported as "the list updates one step behind" or "only when I click something else". ```jsx function addRight(newItem) { setItems([...items, newItem]); // fresh array, different reference } ``` ## The recipes Every update produces a **new** container and leaves the old one untouched: - append: `[...items, newItem]`; prepend: `[newItem, ...items]` - remove: `items.filter(i => i.id !== id)` - replace one: `items.map(i => (i.id === id ? { ...i, done: true } : i))` - object field: `setUser({ ...user, name })` Note the nested case in the `map` example: replacing an item means a new item object **and** a new array. Spreading only the outer level while mutating an inner object gives you a half-fixed update, where the component re-renders but a memoized child that receives the inner object sees no change. If the shape is deep enough that this becomes error-prone, the honest answers are to flatten the state, split it into several `useState` calls, or move to a reducer — not to write ever-deeper spread chains. ## Can you rely on the bail-out? This is the senior half of the question. React documents that if the new value is identical to the current one by `Object.is`, it may skip re-rendering the component and its children — but that it **may still call your component once** before deciding to bail out. So: - **Correctness:** never depend on the component not running. Your render function has to be pure anyway, so an extra call must be harmless by construction. - **Performance:** it is a genuine optimisation, and setting state to an unchanged value is cheap — but it is React's choice, not your API. Do not design an architecture whose render budget depends on the bail-out firing. - **Sloppy sets:** it does make "set it every time and let React sort it out" tolerable for primitives, for example writing the same scroll position repeatedly. It does nothing for objects, because a freshly built object is never identical to the old one — `setUser({ ...user })` with no change still re-renders. That last point is the practical trap in the other direction: code that rebuilds an object on every event will re-render every time, no matter how equal the contents are. ## How to spot it in review The reviewable signals are the mutating methods on a value that lives in state: `push`, `pop`, `splice`, `sort`, `reverse`, and any `obj.field = x` on something reached from state. Two of these are especially sneaky because they look like queries: `sort` and `reverse` mutate in place and return the same array. Linting against mutation, or freezing state objects in development, catches the class rather than the instance. ## The compact answer "React compares with `Object.is`, so a mutated array is the same reference and the update is dropped; a spread produces a new reference and it lands. And the bail-out when values match is an optimisation React is allowed to make — it may still render the component once — so I keep renders pure and never build behaviour on it."
- Does setting state to a newly built object with identical contents skip the re-render?No. `Object.is({...user}, user)` is false because the spread allocates a new object, so React sees a change and re-renders even though every field matches. That is why rebuilding objects on every event is a common source of avoidable renders, and why equality has to be about identity discipline rather than content.
- Why is Array.prototype.sort a particular hazard for state?It sorts in place and returns the same array, so `setRows(rows.sort(cmp))` both corrupts the value React holds and compares identical, dropping the update. Copy first — `[...rows].sort(cmp)` — or use the non-mutating `toSorted`. `reverse` and `splice` carry the same trap for the same reason.
- If React may render the component anyway when the value is unchanged, what must stay true of your render function?It must be pure: given the same props, state and context it returns the same output and causes no side effects. Then an extra call is invisible. React leans on that purity for bail-outs, concurrent rendering and its development double-invocation, so any render-time mutation or logging you count on is already broken.
- How do you keep deep updates from becoming unreadable spread chains?Change the shape rather than the syntax: flatten nested state, split unrelated fields into separate useState calls, or normalise collections by id. When the transitions are genuinely complex, a reducer keeps the immutable copying in one tested place instead of scattering it across handlers.
saying these in an interview costs you the question
- Says React deep-compares state objects to detect changes
- Thinks calling the setter always re-renders, whatever the value
- Believes push then setItems works because the contents changed
- Uses sort or reverse on state and calls it a copy
- Relies on the bail-out to guarantee the component never runs