The Case for Using Immer

26 Jul 2019

In this post, I’d like to convince you to use Immer, 2019’s hottest JavaScript framework.

What is Immer?

Immer is a tiny package that allows you to work with immutable state in a more convenient way.

The basic idea is that you will apply all your changes to a temporary draftState, which is a proxy of the currentState. Once all your mutations are completed, Immer will produce the nextState based on the mutations to the draftState. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data.

Why Immer?

We write a lot of boilerplate when using Redux and updating our store. Every developer familiar with even remotely complex reducers has seen this pattern before:

const someReducer = (state, action) => {
  const newEntry = { 
      ...state.entries[action.payload.id], 
      completed: true 
  }
  return {
      ...state, 
      entries: { 
          ...state.entries, 
          [action.payload.id]: newEntry 
      } 
    }
}

All that code just to set one entry to completed: true! This “extra” destructuring work is required because Redux needs us to return a complete copy of the state.

What if we could just modify the entry we want, and Redux could figure out the rest? Well, that may be expecting too much of Redux, but luckily, we can now use Immer!

With Immer, the above code would look like:

import produce from "immer"

const someReducer = produce((state, action) => {
  state.entries[action.payload.id].completed = true
})

From a developer point of view, I think the benefits are obvious. We’re no longer spending time trying to destructure and then restructure state. We’re just performing a simple operation and moving on with our lives.

Generic Examples

To begin with, what does an Immer function even look like?

Here’s an example:

import produce from 'immer'

const firstState = {}

const secondState = produce(firstState, draft => {
  // empty function
})

console.log(secondState === firstState) // true

// Let's begin using Immer by adding a { 'key': 'value' } pair to the state
const thirdState = produce(firstState, draft => {
  draft['key'] = 'value'
})

console.log(firstState) // {}
console.log(thirdState) // { key: 'value' }

// Now we get to see the magic of Immer
const fourthState = produce(thirdState, draft => {
  delete draft['key']
  draft['newKey'] = { one: 'two' }
  draft['rubicon'] = 'awesome'
})

// thirdState is unmodified, of course!
console.log(thirdState) // { key: 'value' }
console.log(fourthState)
// {
//   newKey: { one: 'two' },
//   rubicon: 'awesome'
// }

Okay, let’s do something a little more realistic. Let’s pretend we’re building an app where users can check a certain field. A todo list or grocery shopping app would make sense.

import produce from 'immer'

const state = [
  {
    id: 1,
    checked: false
  },
  {
    id: 2,
    checked: false
  }
]

const result = produce(state, draft => {
  draft[0].checked = true
  draft.push({ id: 3, checked: false })
})

console.log(result)

// [
//   { id: 1, checked: true },
//   { id: 2, checked: false },
//   { id: 3, checked: false }
// ]

Real World Examples

I think you’re probably starting to get the idea. But let’s get into why this would work for advanced applications where you’re using and abusing Redux heavily.

Here is a real example of a very painful piece of code that we had written at work. We have a UX flow that allows the user to change the height of a displayed chart. The only thing this action handler wants to do is update the height property of a specific chart. But unfortunately, we have to fully recreate the state via destructuring - and it gets ugly!

Before Immer
export const setChartHeightActionHandler = (state, { meta }) => {
  const { chartId, height } = meta
  const { byId } = state

  const chartObject = { ...byId[chartId] }
  const { chartDimensions } = chartObject
  const thisChartDimensions = chartDimensions[chartId]

  return {
    ...state,
    byId: {
      ...byId,
      [chartId]: {
        ...chartObject,
        chartDimensions: {
          ...chartDimensions,
          [chartId]: {
            ...thisChartDimensions,
            height // This is literally the only change made
          }
        }
      }
    }
  }
}

Ugh! That is pretty gross. Let’s use Immer instead.

After Immer
import produce from "immer"

export const setChartHeightActionHandler = produce((state, { meta }) => {
  const { chartId, height } = meta
  state.byId[chartId].chartDimensions[chartId].height = height
})

Boom! The exact same functionality as above.

(If this example doesn’t win you over, I have nothing to offer you.)

Guess which one is easier to understand? Guess which one doesn’t take 15 minutes and two developers to write? (True story).

Let’s refactor another action handler - this one deletes a single entry in the store.

Before Immer
export const deleteExperimentActionHandler = (state, { meta }) => {
  const { experimentId, projectId } = meta

  if (!state.byId[experimentId]) return state

  const byId = {
    ...state.byId
  }
  delete byId[experimentId] // The only operation actually needed

  return {
    ...state,
    byProject: {
      [projectId]: Object.values(byId)
    },
    byId
  }
}
After Immer
import produce from "immer"

export const deleteExperimentActionHandler = produce((state, { meta }) => {
  // Returning undefined is the same as saying "keep the same state"
  if (!state.byId[meta.experimentId]) return
  delete state.byId[meta.experimentId]
})

Is that super dope or what? We accomplish the same task with two lines of code.

Caveats

Immer does have a downside - namely, it’s about twice as slow as a well-written reducer. Immer recommends not using this library when operating on tens of thousands of objects - otherwise, the performance difference is neglible.

Summary

Use Immer wherever it makes sense to you. It’s easy to start with, and the developer benefits will become clear immediately. Your functions will also become easier to test and debug, since you’ll be rid of the noise that comes along with re-creating state all the time!

For those of you using TypeScript - the Immer library is strongly typed! Very cool!