DEV Community

Steven Mercatante
Steven Mercatante

Posted on • Edited on • Originally published at stevenmercatante.com

Simplify Your Redux Reducers with Immer


This post was originally published at https://stevenmercatante.com


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:

If you like MobX, I highly recommend following along @mweststrate’s work on Immer. While MobX is pretty far removed from the vision of where we’re going with React. Immer is dead on.

— Sebastian Markbåge (@sebmarkbage) August 23, 2018

👋 Enjoyed this post?

Join my newsletter and follow me on Twitter @mercatante for more content like this.

Top comments (4)

Collapse
 
manishrana profile image
Manish Rana

You made it very clear to understand. Especially me being a beginner in React and Redux and of course the concept of immutability. Stating of the problem was very helpful to understand why to use the immer.

Collapse
 
thebinarymutant profile image
Smit Patel

Great job man!

Collapse
 
mercatante profile image
Steven Mercatante

Thanks! If you liked this article, you should check out Michel Westrate's (the creator of Immer) new course on Immer. I can't think of a better place to learn more about Immer from the source :)

Collapse
 
swashata profile image
Swashata Ghosh

Thank you very much for this. I would like to add that I am using immer with zustand

import { create } from 'zustand';
import { redux, devtools } from 'zustand/middleware';
import { produce } from 'immer';

type MyReducerStateType = {
  count: number;
}

function myReducer(state: MyReducerStateType, action: 'increase' | 'decrease') {
  return produce(state, draftState => {
    switch (action) {
      case 'increase':
        draftState.count = state.count + 1;
        break;
      case 'decrease':
        draftState.count = state.count - 1;
        break;
      default:
        throw new Error('Invalid action type');
    }
  });
}

// create store with zustand
const [useStore] = create(devtools(redux(myReducer, {
  count: 0,
})));

// as given by redux middleware
const dispatch = useStore(store => store.dispatch);

This has drastically reduced my reducer logics.