Using Immer for Temporary Mutations

Learn how to use Immer to resolve temporary mutations.

We'll cover the following

A word from our friend Michel Weststrate, creator of MobX and Immer:

When writing reducers, it can sometimes be beneficial to use mutable objects temporarily. This is fine as long as you only mutate new objects (and not an existing state) and as long as you don’t try to mutate the objects after they have left the reducer.

Immer is a tiny library that expands this idea and makes it easier to write reducers. It is comparable in functionality to the withMutations() method in Immutable.js, but applied to regular JavaScript structures. With this approach, you don’t have to load an additional library for data structures or learn a new API to perform complex mutations. Additionally, TypeScript and Flow can type-check the reducers created using Immer.

Example

The Immer package exposes a produce() function that takes two arguments: the current state and a producer function. The producer function is called by produce() with a draft.

The draft is a virtual state tree that reflects the entire current state. It will record any changes you make to it. The produce() function returns the next state by combining the current state and the changes made to the draft.

So, let’s say we have the following example reducer:

const byId = (state, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }

    default:
      return state
  }
};

This may be hard to grasp at first glance because there is quite a bit of noise resulting from the fact that we are manually building a new state tree. With Immer, we can simplify this to:

const byId = (state, action) =>
  produce(state, draft => {
    switch (action.type) {
      case RECEIVE_PRODUCTS:
        action.products.forEach(product => {
          draft[product.id] = product
        });
        break;
    }
  });

The reducer will now return the next state produced by the producer. If the producer doesn’t do anything, the next state will continue to be the original state. Because of this, we don’t have to handle the default case.

Immer will use structural sharing, just like if we had written the reducer by hand. Beyond that, because Immer knows which parts of the state were modified, it will also make sure that the modified parts of the tree will automatically freeze in development environments. This prevents accidentally modifying the state after produce() has ended.

To further simplify reducers, the produce() function supports currying. It is possible to call produce() with just the producer function. This will create a new function that will execute the producer with the state as an argument. This new function also accepts an arbitrary amount of additional arguments and passes them on to the producer. This allows us to write the reducer solely in terms of the draft itself:

const byId = produce((draft, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      action.products.forEach(product => {
        draft[product.id] = product
      });

      break;
  }
})

If you want to take full advantage of Redux but still like to write your reducers with built-in data structures and APIs, make sure to give Immer a try.

Get hands-on with 1200+ tech skills courses.