Write cleaner and easy to maintain reducers using Immer

Write cleaner and easy to maintain reducers using Immer

One of the core principles of using Redux is to write pure reducers which instead of mutating the state return a new one with the new changes. But as the complexity of your React application grows with time, your redux state also becomes more and more complex. This means that in order to mutate a nested property you will have to use the ES6 spread operator (...) to create shallow copies of the object at each nesting level and then apply your modifications. Let's see an example :




const initialState={
    user:{
        cart:{
            products:[
                {
                    id:1,
                    name:'Milk',
                    price:4,
                    quantity:1
                },
                {
                    id:2,
                    name:'Butter',
                    price:2,
                    quantity:1
                },
                {
                    id:3,
                    name:'Honey',
                    price:3,
                    quantity:1
                }
            ]
        }
    }
}

Here you can see a user's cart state in an e-commerce app. Each product in your products list UI will have a '+' and '-' button with the ability to dispatch a CHANGE_QUANTITY action which updates the product's quantity. Let's write the reducer handling such action :

const cartReducer=(state=initialState,action)=>{
    switch(action.type){
        case 'CHANGE_QUANTITY':{

            const increment=action.payload.mode=='INCREMENT'?1:-1; // Click on '+' => mode='INCREMENT', Click on '-'=>mode='DECREMENT'

            return{
                ...state,
                user:{
                    ...state.user,
                    cart:{
                        ...state.user.cart,
                        products:state.cart.products.map((product)=>{
                            if(product.id===action.payload.productId) // Change the quantity of product where payload productId to update matches the product's id 
                            {
                                return{
                                    ...product,
                                    quantity:product.quantity+increment
                                }
                            }
                            return product; 
                        }).filter((product)=>{
                            return product.quantity>0;
                        })
                    }
                }
            }
        }
    }
}

WOAH! That's a lot of code. Let's break it down:

  • The user dispatches the action CHANGE_QUANTITY using an onClick event handler like this

function changeQuantity(mode,productId) {
    dispatch({
        type:'CHANGE_QUANTITY',
        payload:{
            mode, //'INCREMENT/DECREMENT',
            productId
        }
    })
}
  • In our reducer we construct a new object with shallow copies at each nesting level using ... operator and map over the products array to modify the quantity for the product with product.id===action.payload.productId.

  • The filter chained to the map is to make sure every time we decrement the quantity of a product with quantity==1, the product is removed from the listing.

Our cart functionality works perfectly fine but requires a lot of boilerplate code for creating shallow copies at each level. This is where immer comes to our rescue.

What is Immer?

According to the documentation, immer is a javascript library using which we can:

Create the next immutable state tree by simply modifying the current tree

What does this mean? Let's recreate our cartReducer using immer to understand

import {produce} from 'immer';

const cartReducer=produce((draft,action)=>{
    switch(action.type){
        case 'CHANGE_QUANTITY':{
            const increment=action.payload.mode=='INCREMENT'?1:-1; // Click on '+' => mode='INCREMENT', Click on '-'=>mode='DECREMENT'

            draft.user.cart.products=draft.user.cart.products.map((product)=>{
                if(product.id==action.payload.productId) // Change the quantity of product where payload productId to update matches the product's id 
                            {
                                return{
                                    ...product,
                                    quantity:product.quantity+increment
                                }
                            }
            }).filter((product)=>{
                return product.quantity>0;
            })
            break;
        }
    }
},initialState);

In immer, we make use of the produce function to update our state tree. The above code is a curried producer which takes in two arguments - reducer and initialState. Here draft is the proxy of our current state, which records all the mutations, creates necessary copies without affecting the original state. The produce applies all these mutations to our original state tree and creates a new state tree. This approach has two main benefits:

  • You are able to avoid the boilerplate code of creating shallow copies for each nesting level making the code more readable.
  • You can use simple JS functions like push, pop, splice etc. to mutate the draft object making the code more maintainable.

So in my opinion immer is a great library to use along with Redux when you are handling a complex state tree. BTW, Redux Toolkit created by the authors of Redux, also makes use of immer internally. Another reason to try it out in your next project!