Immutability in React state
Immutability in React state means creating new objects or arrays instead of modifying existing ones, because React compares references to detect changes, not the actual values inside.
Theory
TL;DR
- React uses
Object.is()(reference equality) to check if state changed. Not deep comparison. - Mutating state directly keeps the same reference, so React sees no change and skips the re-render.
- Create new objects with spread:
{ ...obj, key: newValue }. Create new arrays withmap,filter, or spread. - Decision rule: if you're writing
state.something = value, you're mutating. That won't work.
Quick example
const [user, setUser] = useState({ name: "Alice", age: 25 });
// ❌ Wrong - mutates in place, same reference, no re-render
user.age = 26;
setUser(user);
// ✅ Right - new object, new reference, React re-renders
setUser({ ...user, age: 26 });
// Component re-renders, displays age: 26The spread operator creates a new object in memory. React compares the old and new references, sees they differ, and queues a re-render.
How React detects state changes
React stores a reference to your state in its internal fiber tree. Every setState call triggers a comparison: Object.is(prevState, nextState). If that returns true, React skips the component's render function entirely. If false, it schedules a re-render and reconciles the DOM.
So setUser(user) after mutating user.age does nothing. The variable user still points at the same object in memory. React checks, gets true, moves on.
This mechanism also explains why React.memo, useMemo, and useCallback work the way they do. All three are built on the same reference comparison.
When to use each approach
- Single field update on an object:
setUser({ ...user, age: 26 }) - Nested object update: spread each level,
setState({ ...state, user: { ...state.user, name: "Bob" } }) - Add to array:
setItems([...items, newItem]) - Remove from array:
setItems(items.filter(item => item.id !== id)) - Update one item in array:
setItems(items.map(item => item.id === id ? { ...item, ...changes } : item)) - Deeply nested state with many levels: use Immer
Immutable operations reference
| Operation | Mutating | Immutable |
|---|---|---|
| Add to array | push(), unshift() | [...arr, item] |
| Remove from array | splice(), pop() | filter() |
| Replace in array | arr[i] = x | map() |
| Sort array | sort() | [...arr].sort() |
| Update object field | obj.key = val | { ...obj, key: val } |
| Delete property | delete obj.key | const { key, ...rest } = obj |
Common mistakes
1. Mutating a nested object
// ❌ Wrong - outer reference unchanged, React skips re-render
state.user.name = "Bob";
setState(state);
// ✅ Right - new outer object AND new nested object
setState({ ...state, user: { ...state.user, name: "Bob" } });React checks the outer object reference. It hasn't changed. Re-render skipped.
2. Using mutating array methods
// ❌ Wrong - push() modifies the original, same reference
items.push(newItem);
setItems(items);
// ✅ Right - new array reference
setItems([...items, newItem]);push(), pop(), splice(), sort(), and reverse() all modify the original array. Replace them with non-mutating alternatives.
3. Thinking spread covers nested objects
// ❌ Partially wrong - drops any other fields address had
setUser({ ...user, address: { city: "NYC" } });
// ✅ Right - spread the nested level too
setUser({ ...user, address: { ...user.address, city: "NYC" } });Spread is shallow. It copies top-level properties, but nested objects are still shared references.
4. Mutating an item inside an array, then spreading the array
// ❌ Wrong - new array, but todos[0] is still the same object reference
const next = [...todos];
next[0].done = true;
setTodos(next);
// ✅ Right - new array, new item object
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: true } : todo
));In practice, this is the sneakiest mutation bug. Developers who already know "don't mutate state directly" spread the array, feel confident, and then spend an hour debugging why the list item didn't update. The array is new. The objects inside are not.
5. Reading state right after setState
// ❌ Wrong assumption - count is still the old value here
setCount(count + 1);
console.log(count);
// ✅ Use the updater function when next state depends on previous
setCount(prev => prev + 1);setState schedules an update for the next render. It doesn't change the variable synchronously.
Real-world usage
useState- every update needs a new reference to trigger re-render- Redux reducers - must return new state, never mutate the argument passed in
- Zustand - store update functions should return new objects for predictable state
- React Query - cache updates follow the same pattern for consistency
- Immer - lets you write mutation-style code that produces immutable results under the hood
React.memoanduseMemo- stable references prevent unnecessary re-renders of child components
Follow-up questions
Q: Why does React use reference comparison instead of deep equality?
A: Deep equality checks every property recursively, which is O(n) per state update. Reference comparison is O(1). It also makes intent explicit: creating a new object signals "this is a changed value."
Q: What if I accidentally mutate state but the component still re-renders because a parent prop changed?
A: The component re-renders, but with corrupted state. Your mutation changed the old state object. React's internal state was never updated. The render uses whatever is in the fiber, ignoring your mutation. You get a visual mismatch and a hard-to-trace bug.
Q: How does Immer solve deeply nested updates without endless spread operators?
A: Immer wraps your state in a Proxy called a "draft." You write code that looks like direct mutation (draft.user.address.city = "NYC"), and Immer tracks every change. When you're done, it produces a new immutable object. It only copies branches that were actually modified, so it stays efficient even for large state trees.
Q: I have 10,000 items in an array. Doesn't creating a new array on every update waste memory?
A: Spreading large arrays is fast in modern JS engines, so it rarely matters. But if you update individual items frequently, normalize your state: store items as an object keyed by ID instead of an array. Then updating one item is a single property assignment, not a full array copy. Most apps never need to think about this.
Examples
Updating form state with nested preferences
const [formData, setFormData] = useState({
email: "",
preferences: { notifications: true, theme: "dark" }
});
function handleEmailChange(e) {
// Only email changes; preferences reference stays the same - that's fine
setFormData({
...formData,
email: e.target.value
});
}
function toggleNotifications() {
// New outer object AND new preferences object
setFormData({
...formData,
preferences: {
...formData.preferences,
notifications: !formData.preferences.notifications
}
});
}handleEmailChange creates a new top-level object. The preferences property still points at the same object, which is fine since it didn't change. toggleNotifications needs a new preferences object because that's the level that actually changed.
Updating one item in an array of objects
const [todos, setTodos] = useState([
{ id: 1, title: "Learn React", tags: ["js", "react"] },
{ id: 2, title: "Build app", tags: ["project"] }
]);
function addTagToTodo(todoId, newTag) {
setTodos(todos.map(todo =>
todo.id === todoId
? { ...todo, tags: [...todo.tags, newTag] }
: todo
));
}map() returns a new array. The matching todo gets a new object with a new tags array. Other todos keep their original references. That matters for React.memo on list items - unchanged items won't re-render.
Complex nested updates with Immer
import { produce } from "immer";
const [state, setState] = useState({
users: [
{ id: 1, name: "Alice", scores: [90, 85] }
]
});
function addScore(userId, score) {
setState(produce(draft => {
const user = draft.users.find(u => u.id === userId);
if (user) {
user.scores.push(score); // Immer tracks this and produces a new object
}
}));
}Without Immer, this requires spreading the state object, spreading the users array, finding the user, spreading that user, and spreading the scores array. Five levels of spread for one change. Immer handles all of that internally. The intent is clear.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.