Why use Immer for state updates?

Why use Immer for state updates?

ยท

3 min read

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:

  1. Deep comparison: Look at every single property in userData, recursively checking if any value changed. This is expensive for large objects! ๐Ÿ˜ฌ

  2. 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:

  1. Prone to mistakes (easy to miss a spread)

  2. Difficult to see what changes

  3. 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:

  1. Intuitive โ†’ write code as you normally would โœจ

  2. Safe โ†’ you can't accidentally change the original โœจ

  3. Clear โ†’ you can easily see what's changing โœจ

  4. 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.

ย