Introduction

If you’ve used Redux at some point, theres a good chance you’ve heard of or even used Immer. Immer was created as a way of making Redux reducers easier to deal with.

When React moved from classes to functions, the useState and useReducer were actually inspired by Redux and as a side effect, immer became something we could use with hooks.

Unfortunately, most developers had Redux-Burnout, so most people haven’t touched immer since then, preferring to “keep things simple” with useState. However, sometimes you still end up with complex objects/arrays inside of a useState and end up doing some things that would be considered “weird” if you weren’t using react.

Imagine you’re using a table library (e.g. @mui/x-data-grid-pro) that has you pass in an array of columns (headers) and rows (data). Now lets say you’re building a fintech app that has a toggle to display transactions in usd-$ and euros-€. To do this in react we’ve have to do something like this. Since when we call a useState setter, the array/object passed in needs to have a different reference, we need to use .map to create a copy of our array.

useEffect(() => {
  setColumns(prev => 
    prev.map(column => {
      if (column.field.startsWith('amount')) {
        return props.displayInUSD ? AMOUNT_USD : AMOUNT_EURO
      }
      return column
    })
  )
}, [props.displayInUSD])

This code is alright, but its very “react-y” and can get a lot more complicated and annoying to write. Which is where Immer comes in,

import { produce } from 'immer'
// ...
useEffect(() => {
  setColumns(produce(draft => {
    const column = draft.find(column => column.field.startsWith('amount'))
    column = props.displayInUSD ? AMOUNT_USD : AMOUNT_EURO
  }))
}, [props.displayInUSD])

This feels a lot more natural. We’re just “mutating” the draft (proxy) object, which takes care of translating the “mutation” operations into immutable operations.

Not only is this more natural, Immer also makes the code you write simpler. In my DataGrid example, we can actually take it a step further.

// Outside our component, next to were we define the COLUMNS
const INDEX = draft.findIndex(column => COLUMNS.field.startsWith('amount'))
// ...
const [columns, setColumns] = useState(COLUMNS)
useEffect(() => {
  setColumns(produce(draft => {
    draft[INDEX] = props.displayInUSD ? AMOUNT_USD : AMOUNT_EURO
  }))
}, [props.displayInUSD])

In a more complex example, this might actually make some performance gains.


Immer was so impactful that it even won some awards: React OS awards, JS OS Awards. Libraries like Solidjs (which won its own JS OS award) even integrated immer’s produce API into their Store design.

Use cases

Immer

The most common use case of immer to to avoid destructuring both objects and arrays:

Example from the Immer docs

const [todos, setTodos] = useState([
  {
    id: "React",
    title: "Learn React",
    done: true
  },
  {
    id: "Immer",
    title: "Try Immer",
    done: false
  }
]);
 
const handleToggle = (id) => {
  setTodos(produce(draft => {
    const todo = draft.find((todo) => todo.id === id);
    todo.done = !todo.done;
  }));
}
 
const handleAdd = useCallback(() => {
  setTodos(produce(draft => {
    draft.push({
      id: "todo_" + Math.random(),
      title: "A new todo",
      done: false
    });
  }));
}

Nested Data example from Immer Docs

// example complex data structure
const store = {
  users: new Map([
    [
      "17",
      {
        name: "Michel",
        todos: [
          {
            title: "Get coffee",
            done: false
          }
        ]
      }
    ]
  ])
}
 
// updating something deeply in-an-object-in-an-array-in-a-map-in-an-object:
const nextStore = produce(store, draft => {
    draft.users.get("17").todos[0].done = true
})
 
// filtering out all unfinished todo's
const nextStore = produce(store, draft => {
    const user = draft.users.get("17")
    // when filtering, creating a fresh collection is simpler than
    // removing irrelevant items
    user.todos = user.todos.filter(todo => todo.done)
})
 

See more Common Update Patterns

The useImmer Hook

If you plan on using immer heavilty and want to avoid having to call produce all the time, theres actually a useImmer utility hook that works just like you’d expect:

  • Works like useState when setting a value e.g. setState(42)
  • Works like produce in the callback: setState(draft => { draft.value = 42 })
npm install use-immer

https://github.com/immerjs/use-immer

Resources