Home / Articles

Why push() Leaves Your React UI Stale and How New References Fix It

Understand why mutating arrays in useState never triggers a re-render, how React's reference check works, and when to use spread copies and functional updates.

Publisher: REACTAPP.TOP6 min read1280 words

This article is published in English.

Sources: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI

Few React bugs are as disorienting as a UI that refuses to update while the console insists the data is correct. There is no error and no warning, just a screen stuck on old values. In the overwhelming majority of cases the cause is state mutation: changing an array or object in place instead of replacing it. This article explains exactly why React ignores in-place changes, shows the one-line fix, and covers the functional update form you need when updates pile up quickly.

The bug: the data changes, the screen does not

Picture a small shopping cart, todo list or user table. The last task is adding an item to an array held in useState, and the first attempt looks like ordinary JavaScript.

const [cart, setCart] = useState(['Coffee']);

const addItem = (newItem) => {
  // It's just JavaScript, right?
  cart.push(newItem);
  console.log("Added! Cart is now:", cart);
};

After clicking "Add to Cart", the log shows ['Coffee', 'Tea'], so the data looks right. The rendered cart, however, still lists only "Coffee". The interface is now showing users something that is no longer true, which is worse than a crash because nobody notices until a customer does. Developers in this situation often lose an hour rechecking loops and rendering logic that were never the problem.

Notice that this handler never calls setCart at all. Nothing told React that anything happened, so it had no reason to render. The more subtle version of the bug adds setCart(cart) after the push(), and the screen still does not change. The next section explains why.

React compares references, not contents

The key idea to internalize is simple: React tracks identity, not content.

Methods such as .push(), .pop(), .splice() and .unshift(), or an assignment like user.name = 'Alice', are mutations. They tell JavaScript to change the value stored at an existing location in memory. JavaScript does so immediately, which is why console.log(cart) reports the new item. React, however, is not watching that memory. Its update model assumes state is treated as immutable.

When you call a state setter, React compares the new value with the current one using Object.is. For arrays and objects, that is a reference comparison: it only asks whether this is the very same object as before, not whether its contents differ. After a push(), the array you pass back is the same object React already holds. The check says "unchanged", React skips the re-render, and the UI keeps showing the last committed version.

This cheap check is deliberate. Comparing references takes constant time regardless of how large the state is, while deep comparison of every nested value on every update would be expensive. The trade-off is that you must signal change by providing a new reference.

The fix: copy, change the copy, set the copy

Immutability sounds more demanding than it is. To update an array or object in state, leave the original alone, build a new one that includes the change, and pass that to the setter. In modern JavaScript the spread operator (...) is the quickest way to do that.

The corrected addItem creates a new array containing the old items followed by the new one.

const addItem = (newItem) => {
  // The right way (creating a new identity):
  setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};

Now the setter receives a different reference, the Object.is check fails, and React re-renders with the added item. The same principle applies to other operations:

  • Removing: cart.filter(item => item !== target) instead of splice().
  • Replacing: cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
  • Updating an object: setUser({ ...user, name: 'Alice' }) instead of assigning to a property.
  • Sorting or reversing: copy first with [...cart].sort(), or use the non-mutating toSorted() and toReversed() where your target browsers support them.

Keep in mind that spread copies only one level. If state contains nested objects, every level you change needs its own copy. For a deeper look at reference mutation beyond React state, including nested structures, see avoiding silent state bugs from JavaScript reference mutation.

What immutable updates buy you

This is not just a rule React imposes; it produces code that is easier to reason about.

  1. Clear update points. Every state change goes through a setter, so it is obvious which functions modify state and where.
  2. Better debugging. Because each update produces a distinct value, React DevTools and other tooling can show the state before and after every change.
  3. No hidden side effects. When data is never modified in place, a bug in one component cannot quietly corrupt an array or object that another part of the app is still reading.

Functional updates for rapid or overlapping changes

A second, related problem appears with high-frequency updates such as quick repeated clicks or several asynchronous responses arriving close together, possibly out of order. Each handler captures the state value from the render in which it was created. If two updates run before a re-render, both may start from the same stale snapshot, and one change overwrites the other. This is the stale closure problem, and it interacts directly with how you build new state.

The remedy is to pass a function to the setter. React calls it with the latest pending state, so each update builds on the result of the previous one rather than on a captured value. The example below appends a batch of generated items. It reads prevCart.length from the argument to number them, then returns a brand-new array that spreads the previous items and the new ones.

// A very fast counter:
const addManyItems = (count) => {
  // Good: Uses spread to be immutable
  // Better: Uses functional update for consistency
  setCart(prevCart => {
    // We are generating new array content:
    const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
    // Spread operator for the final, clean, new identity:
    return [...prevCart, ...newItems];
  });
};

Two things work together here. The spread still produces a new reference, which is what makes React re-render. The updater function guarantees that the base array is the most recent one, which is what prevents lost updates. A good habit is to use the functional form whenever the next state is computed from the previous state.

Wrapping up

  • A mutation changes the data but not the reference, so React's Object.is check concludes nothing changed and skips rendering.
  • When the UI lags behind the data, first look for .push(), .unshift(), .splice() or state.property = value on state.
  • Build a new array or object with spread, map or filter, and copy each nested level you change.
  • Use the functional updater when new state depends on old state, especially with rapid or asynchronous updates.

The fix usually costs a single line, and it is the difference between an interface users can trust and one that silently shows stale information.