React2 min read

You pushed to the array and nothing rendered

React compares state with Object.is, so changing an array or object in place looks like no change at all. Here are the copy patterns, including the one for nested data.

You add an item and the list does not update:

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

const add = (item) => {
  items.push(item);   // the array now has it
  setItems(items);    // React does nothing
};

The data is correct. The screen is wrong. Sometimes it even appears later, when something unrelated triggers a render, which makes it look intermittent.

The mental model

React decides whether to re-render by comparing the new state with the old one using Object.is. For objects and arrays, that compares identity, not contents.

items.push(item) changes the array in place. The identity is the same. So setItems(items) is passing React the value it already has, and React correctly concludes nothing changed.

The rule follows: give React a new object, do not edit the old one.

Arrays

Do not use methods that change the array in place: push, pop, shift, unshift, splice, sort, reverse.

// add
setItems((current) => [...current, item]);

// remove
setItems((current) => current.filter((i) => i.id !== id));

// update one
setItems((current) =>
  current.map((i) => (i.id === id ? { ...i, done: true } : i)),
);

// insert at an index
setItems((current) => [
  ...current.slice(0, index),
  item,
  ...current.slice(index),
]);

For sorting and reversing, copy first, or use the newer methods that return a copy:

setItems((current) => [...current].sort(byName));
setItems((current) => current.toSorted(byName)); // also toReversed, toSpliced, with

toSorted and friends are available in every current browser and in Node 20 and later.

Objects, including nested ones

setUser((current) => ({ ...current, name: "Rahul" }));

The spread is shallow, so nested objects need copying at each level you change:

setUser((current) => ({
  ...current,
  address: { ...current.address, city: "Ahmedabad" },
}));

Three or four levels of this is a smell. Two ways out.

Flatten the state. Deeply nested state is usually a sign the shape is wrong. A map of items by id is easier to update than a tree.

Or use a helper. Immer lets you write the mutating version and produces a new object for you:

setUser(
  produce((draft) => {
    draft.address.city = "Ahmedabad";
  }),
);

useReducer plus Immer covers most complicated state without hand written spreads.

Why StrictMode surfaces this

In development React calls your component function, your state initialiser, and your reducer twice, then keeps one result. Code that mutates something outside itself runs twice and doubles its effect, so a mutation bug that hid before now shows up as duplicated items or a doubled counter.

That is a feature. A double render should be invisible. If it is not, something is mutating.

The linter catches most of it

eslint-plugin-react-hooks will not flag mutation, but eslint-plugin-immutable or a simple review habit will. The one I use: any time I see a state variable on the left of an assignment or followed by .push(, I stop and copy instead.

Written by Rahul Mourya, full stack engineer. Spotted a mistake or want the follow-up post? Email me.

All posts
  • React2 min read

    Delete the effect that copies props into state

    Syncing state with useEffect gives you an extra render, a flash of stale data, and two sources of truth. Most of the time you can calculate the value during render instead.

    • state
    • effects
    • rendering
  • React2 min read

    One context is re-rendering your whole app

    Context has no selectors. Every consumer re-renders when the value changes, even the ones using a field that did not. Splitting the context fixes most of it.

    • context
    • performance
    • state