Why does React recommend against mutating state?
TL;DR
React recommends against mutating state because several of its mechanisms depend on the previous and next state being different objects (reference inequality). When you mutate state in place, the reference does not change, which breaks Object.is bailouts in useState/useReducer, breaks React.memo and useMemo/useEffect dependency comparisons, and can cause tearing under concurrent rendering. It also defeats time-travel debugging in React DevTools. Always produce a new object/array (with the spread operator, array methods like map/filter/toSorted, or a library such as Immer) and pass it to the state setter.
Why does React recommend against mutating state?
A quick terminology note
In modern React, state is updated by the setter returned from useState (or by dispatch from useReducer). The class-based this.setState API still exists but is rarely used in new code. Both APIs share the same expectation: you give React a new state value rather than mutating the existing one. The rest of this answer focuses on the hooks-based APIs.
Where reference equality actually matters
A common misconception is that mutating state breaks the "virtual DOM diff." That is not quite right — the diff happens against the rendered element tree, not against the state object. The places where state immutability genuinely matters are:
Object.isbailout inuseState/useReducer: When you call the setter (or return a value from a reducer), React compares the new value with the current one usingObject.is. If they are the same reference, React can skip re-rendering the component. Mutate-in-place returns the same reference, so React thinks nothing changed and skips the render — but the data has actually changed, so the UI goes stale.React.memo,useMemo,useCallback, anduseEffectdependencies: These all compare values across renders withObject.is. A mutated array or object still has the same reference, so memoized children will not re-render and effects will not re-run, even though their underlying data is now different.- Concurrent rendering and tearing: Since React 18, rendering can be interrupted, paused, or even abandoned. If you mutate state during a render, an in-progress render and a discarded render can read different values from the same object — producing tearing, where different parts of the UI reflect different versions of the state.
- DevTools and time-travel debugging: React DevTools (and tools like Redux DevTools) rely on snapshots of past states to let you step backwards. Mutation overwrites those snapshots, so the history becomes meaningless.
React schedules a render either way
Calling setState/dispatch always schedules a render — React does not refuse to render because you passed the same reference. What the reference comparison controls is what happens after the render is queued: whether the component bails out (skips actually rendering), and whether downstream React.memo/useMemo/useEffect consumers see a "change." Mutation is a problem precisely because it produces "looks the same to React, actually different in memory" — the worst of both worlds.
Problems with mutating state
- Stale UI updates: The bailout above means the UI does not refresh when the underlying data changed.
- Broken memoization: Memoized children, memoized values, and effects all compare by reference and will silently skip updates.
- Tearing under concurrent features such as
startTransitionandSuspense. - Lost debuggability: Time-travel and "previous props/state" inspection in React DevTools stop working correctly.
- Hard-to-track bugs: Multiple components or hooks may close over the same object reference. Mutating it can have spooky action-at-a-distance effects.
How to update state correctly
Always produce a new value:
const [user, setUser] = useState({ name: 'Ada', age: 36 });// Incorrect: mutates the existing object — same reference, bailout fires.user.age = 37;setUser(user);// Correct: a brand new object.setUser({ ...user, age: 37 });// Equally correct, and safer when the new value depends on the old one// (avoids stale-closure issues across batched updates):setUser((prev) => ({ ...prev, age: 37 }));
For arrays, prefer non-mutating methods like map, filter, concat, the spread operator, or the newer toSorted/toReversed/toSpliced (avoid push, splice, sort, reverse on state):
const [items, setItems] = useState([3, 1, 2]);// Incorrect: sort() mutates in place.items.sort();setItems(items);// Correct.setItems([...items].sort((a, b) => a - b));// Or, with the modern non-mutating method:setItems(items.toSorted((a, b) => a - b));
When the spread gets painful: Immer
Spreading deeply nested state by hand is verbose and error-prone. Immer is the de facto solution: you write code that looks like mutation against a draft, and Immer produces a new immutable state for you. It is built into Redux Toolkit's createSlice, and the useImmer / useImmerReducer hooks plug directly into React.
import { produce } from 'immer';setUser((prev) =>produce(prev, (draft) => {draft.address.city = 'Singapore';}),);
You get the ergonomics of mutation and the guarantees of immutability.