In React (and modern JavaScript in general), we aim for immutable state updates.
Why? Because comparing objects by reference is MUCH faster than doing a deep comparison. If an object's reference hasn't changed, we can be certain that nothing inside it has changed.
Deep vs Reference comparison
In React, imagine you have a component that receives a large object as a prop:
const UserDashboard = ({ userData }) => {
// userData might be:
// {
// profile: { ... },
// posts: [ ... ],
// friends: [ ... ],
// settings: { ... }
// }
}
React needs to decide if it should re-render this component when the parent updates.
It has two options:
Deep comparison: Look at every single property in userData, recursively checking if any value changed. This is expensive for large objects! ๐ฌ
Reference comparison: Just check if
userData === prevUserData
. This is just one operation. Much faster!
// Deep comparison (slow)
function isEqual(obj1, obj2) {
for (let key in obj1) {
if (typeof obj1[key] === 'object') {
if (!isEqual(obj1[key], obj2[key])) return false
} else if (obj1[key] !== obj2[key]) {
return false
}
}
return true
}
// Reference comparison (fast!)
userData === prevUserData // single operation
This is why React uses reference equality by default (like in useMemo
, useEffect
dependencies, React.memo
, etc).
Catch with direct mutation
The catch: For this to work reliably, we need to ensure that if we change ANY part of an object, we get a new reference.
If we mutate directly:
const userData = { name: "John", age: 30 }
userData.age = 31 // Same reference! React won't know it changed ๐ด
Immutable mutation
const userData = { name: "John", age: 30 }
const newUserData = { ...userData, age: 31 } // New reference! React can detect this
The pain without Immer
Here's the pain point:
const state = {
users: [
{ id: 1, name: "John" },
{ id: 2, name: "Mary" }
],
settings: {
theme: "dark",
notifications: {
email: true,
push: false
}
}
}
// Want to update Mary's name and enable push notifications?
const newState = {
...state,
users: state.users.map(user =>
user.id === 2 ? { ...user, name: "Mary Jane" } : user
),
settings: {
...state.settings,
notifications: {
...state.settings.notifications,
push: true
}
}
}
Look at all that spreading!
This is:
Prone to mistakes (easy to miss a spread)
Difficult to see what changes
Becomes more complicated with deeper state
Immer's smart idea: What if we could write code in the usual way but still get updates that don't change the original data?
const newState = produce(state, draft => {
draft.users[1].name = "Mary Jane"
draft.settings.notifications.push = true
})
This is:
Intuitive โ write code as you normally would โจ
Safe โ you can't accidentally change the original โจ
Clear โ you can easily see what's changing โจ
Efficient โ Immer only copies the parts that actually changed โจ
The magic of Immer is that it creates a proxy of your state (the draft), tracks the changes you make, and only creates new references for the parts that have changed. Everything else keeps the same reference for better performance.