Why shouldn't you mutate state in React?
The Short Answer
React relies on reference equality checks to detect state changes. When you mutate an object or array directly, the reference stays the same — React sees no change and skips the re-render. You must create a new object/array so React can compare the old and new references and know something changed. Beyond re-renders, immutability also enables time-travel debugging, predictable data flow, and safe concurrent rendering.
How React Detects Changes
When you call a state setter, React compares the new value to the old value using Object.is() (essentially === for objects). If the reference is the same, React assumes nothing changed and bails out of the re-render. This is a shallow comparison by design — deep comparison on every state update would be too expensive.
function TodoList() {
const [todos, setTodos] = useState([{ id: 1, text: 'Learn React', done: false }]);
function toggleTodo(id: number) {
// ❌ MUTATION — same array reference, React won't re-render
const todo = todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
setTodos(todos); // Same reference → Object.is(oldState, newState) === true → no render
}
function toggleTodoCorrectly(id: number) {
// ✅ NEW REFERENCE — React sees the change
setTodos(todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text} {todo.done ? '✓' : ''}</li>
))}
</ul>
);
}
In the broken version, todos is mutated in place and then passed back to setTodos. React compares the old and new references — they're the same array object — and concludes nothing changed. The UI stays stale even though the data inside the array is different.
Immutable Update Patterns
The pattern is always the same: create a new container (object or array) with the updated values, leaving the original untouched. The spread operator and array methods like map, filter, and concat are your primary tools.
// --- Objects ---
const user = { name: 'Alice', age: 30, role: 'admin' };
// Update a property
const updated = { ...user, age: 31 };
// Add a property
const withEmail = { ...user, email: 'alice@example.com' };
// Remove a property
const { role, ...withoutRole } = user;
// --- Arrays ---
const items = [1, 2, 3, 4, 5];
// Add to end
const appended = [...items, 6];
// Add to beginning
const prepended = [0, ...items];
// Remove by index
const removed = items.filter((_, index) => index !== 2);
// Update by index
const replaced = items.map((item, index) => index === 2 ? 99 : item);
// --- Nested objects ---
const state = { user: { name: 'Alice', address: { city: 'NYC' } } };
// Update nested property (spread at every level)
const newState = {
...state,
user: {
...state.user,
address: { ...state.user.address, city: 'LA' },
},
};
Notice that nested updates require spreading at every level of the object tree. This can get verbose for deeply nested state — which is one reason to keep state flat or use libraries like Immer that let you write mutation-style code that produces immutable updates under the hood.
Beyond Re-renders: Other Benefits
Immutability isn't just about triggering re-renders. It provides several architectural benefits that make React applications more predictable and debuggable.
- Predictable data flow
- State changes are explicit — you can trace exactly when and where state was updated
- Time-travel debugging
- Redux DevTools can replay state changes because each state is a separate snapshot
- Safe memoization
- React.memo, useMemo, and useCallback rely on reference equality — mutations break them
- Concurrent rendering
- React 18's concurrent features assume state isn't mutated between renders
- Undo/redo
- Keeping previous state snapshots is trivial when state is never mutated
How Mutations Break Memoization
Components wrapped in React.memo only re-render when their props change (by reference). If you mutate an object and pass it as a prop, the reference is the same — the memoized component skips the render even though the data inside changed. This creates stale UI bugs that are extremely hard to track down.
const ExpensiveList = React.memo(({ items }: { items: Item[] }) => {
console.log('Rendering list'); // Won't fire if items reference is the same
return (
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
);
});
function Parent() {
const [items, setItems] = useState<Item[]>([{ id: 1, name: 'A' }]);
function addItem() {
// ❌ Mutation — ExpensiveList won't re-render
items.push({ id: 2, name: 'B' });
setItems(items); // Same reference
// ✅ Immutable — ExpensiveList sees new reference and re-renders
setItems([...items, { id: 2, name: 'B' }]);
}
return <ExpensiveList items={items} />;
}
Using Immer for Complex Updates
For deeply nested state, spreading at every level gets tedious and error-prone. Immer lets you write code that looks like mutations but produces a new immutable state behind the scenes. It uses JavaScript Proxies to track what you change and creates a new object with only the modified paths.
import { produce } from 'immer';
const state = {
users: [
{ id: 1, name: 'Alice', settings: { theme: 'dark', notifications: true } },
{ id: 2, name: 'Bob', settings: { theme: 'light', notifications: false } },
],
};
// Without Immer — verbose nested spreading
const manualUpdate = {
...state,
users: state.users.map((user) =>
user.id === 1
? { ...user, settings: { ...user.settings, theme: 'light' } }
: user
),
};
// With Immer — write mutations, get immutable result
const immerUpdate = produce(state, (draft) => {
const user = draft.users.find((u) => u.id === 1);
if (user) user.settings.theme = 'light';
});
// Both produce the same result — a new state object with the change
Why Interviewers Ask This
This question tests whether you understand React's rendering model at a fundamental level. Interviewers want to hear that you know React uses reference equality for change detection, can explain what happens when you mutate (stale UI, broken memoization), know the immutable update patterns (spread, map, filter), and understand the broader benefits (debugging, concurrent mode, undo/redo). It separates developers who follow patterns mechanically from those who understand why the patterns exist.
Quick Revision Cheat Sheet
Why no mutation: React uses Object.is() (reference check) — same ref = no re-render
Objects: Spread: { ...obj, key: newValue }
Arrays: map/filter/spread: [...arr, newItem] or arr.filter(...)
Nested state: Spread at every level, or use Immer for cleaner syntax
Memoization: React.memo, useMemo, useCallback all rely on reference equality
Concurrent mode: React 18 assumes state isn't mutated between renders