Steven

Mercatante

Steven Mercatante

Simplify Your Redux Reducers with Immer

The Problem

I was using Redux for a project and saw that my reducers were getting kind of gnarly. The code seemed to grow exponentially whenever I needed to work with nested data. This was due to Redux's insistence on using immutable data. I'm a big fan of using immutable data, but it's definitely more... awkward to work with compared to mutable data. Let's look at some examples:

case ADD_TIMER: {
return { [action.payload.id]: action.payload, ...state };
}

I know what you're thinking... "that code isn't awkward - it's just using the spread operator to add an item to an existing object. Easy!" Fine, let's keep going...

case REMOVE_TIMER: {
const newState = { ...state };
delete newState[action.payload.id];
return newState;
}

OK, that's still not too bad, but all I want to do is delete an item from an object. I shouldn't need to create a copy of the existing state, delete the item from the copy, and then return the copy.

case INCREMEMT_RUNNING_TIMERS: {
const updatedTimers = Object.values(state)
.filter(timer => timer.running)
.reduce((acc, timer) => {
timer.totalTime = getTotalTime(true, timer.starts, timer.stops);
acc[timer.id] = timer;
return acc;
}, {});
return { ...state, ...updatedTimers };
}

Good luck convincing me this one can't be improved. In case you're wondering, I'm iterating over the object, filtering on only the ones I want, reducing them into a new object with some updated properties, and finally merging that into the returned state. Yikes.

The Solution

Immer to the rescue! Immer lets you "Create the next immutable state tree by simply modifying the current tree." What's that mean? Let's convert the above code examples to see.

case ADD_TIMER: {
draft[action.payload.id] = action.payload;
break;
}
case REMOVE_TIMER: {
delete draft[action.payload.id];
break;
}
case INCREMEMT_RUNNING_TIMERS: {
Object.values(draft).forEach(timer => {
if (timer.running) {
timer.totalTime = getTotalTime(true, timer.starts, timer.stops);
}
});
break;
}

(Don't worry about that draft variable - we'll talk about that in just a bit...)

Look at that! The code is shorter, easier to read, and easier to understand. But, doesn't this break Redux's need for immutable operations? Nope. Immer is performing immutable operations behind the scenes, but it lets us write mutable operations, which 9 times out of 10, are easier to reason about (not to mention quicker to write). The secret is Immer's concept of a draftState.

The Draft State

Rather than explain it myself, here's how Immer defines the draftState:

"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 draft state. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data."

You need to add a little bit of code to your reducer. Here's a complete example:

import produce from 'immer'
export default (state = {}, action) =>
produce(state, draft => {
switch (action.type) {
case ADD_TIMER: {
draft[action.payload.id] = action.payload
break
}
case REMOVE_TIMER: {
delete draft[action.payload.id]
break
}
case INCREMEMT_RUNNING_TIMERS: {
Object.values(draft).forEach(timer => {
if (timer.running) {
timer.totalTime = getTotalTime(true, timer.starts, timer.stops)
}
})
break
}
default:
return draft
}
})

Make sure you don't forget that call to produce - your reducer won't work without it!

Wrapping Things Up

Immer has become one of my go-to tools whenever I work on a Redux project. It takes minimal time to explain to colleagues & contributors, and provides a bunch of benefits, including:

  • less code to write
  • less code to maintain
  • code that's easier to understand

And in case you need further convincing, check out what one of React's core maintainers has to say about it: