Combining multiple reducers in React.

Combining multiple reducers in React.

Break down a single reducer into multiple reducers in React

ยท

6 min read

In this article, we shall discuss combining multiple reducers to create a main reducer. We would then use the main reducer as an argument to useReducer() hook using react's functional components. Before we could deep dive into this topic, I assume that you are familiar with how useReducer hook works and have a brief understanding of combineReducers(reducers) in Redux. Here's the documentation link if you would want to go through them. useReducer in React | combineReducers in Redux.

The state management functionality of Redux and useReducer are similar. When the user triggers an action, this call is dispatched to the reducer. The required state modifications are performed by the reducer, and the latest values are present in the view. Redux makes use of a store in addition to how useReducer works.

reducer-cycle.png

While building a smaller project, useContext + useReducer is preferred over Redux for state management.

As Dan Abramov says "I would like to amend this: don't use Redux until you have problems with vanilla React."

Why do we need multiple reducers?

While creating a react application, it's completely a programmer's decision to either have a single reducer or to break it down into smaller reducers. I went ahead with this approach for the following reasons:

  • Taking the example of an eCommerce application, we could have one reducer to manage the user's cart/wishlist, another to contain product details, another to handle the user's address, and so on.
  • Dividing reducers based on a given functionality is helpful while writing test cases for each of these features.
  • Having smaller reducers increases the readability of code.
  • Smaller reducers would mean lesser lines of code for a single function, thus keeping it in line with the clean coding practices.
  • We could create smaller reducers and maintain a separate folder structure in this approach.

Implementation with an example application

Let us understand how we could create multiple reducers with an example. We would be using the implementation of an eCommerce application. In this application, we have the following functionalities:

  • Set the list of products on page load
  • Sort the products based on price
  • Add a product to the wishlist
  • Remove a product from the wishlist

I have purposely restricted myself with these functionalities and not introduced the cart functionality. We shall discuss this in the last part of the article.

Link to CodeSandbox

We have a single reducer which is named dataReducer.js.

// dataReducer.js

import { ActionTypes } from "./actions";
import { toast } from "react-toastify";

export const initialState = {
  products: [],
  sortBy: "",
  wishlist: [],
};

export const reducer = (state, action) => {
  switch (action.type) {
    case ActionTypes.SET_PRODUCTS: {
      return {
        ...state,
        products: action.payload,
      };
    }
    case ActionTypes.SORT_BY_PRICE: {
      return {
        ...state,
        sortBy: action.payload.value,
      };
    }
    case ActionTypes.ADD_TO_WISHLIST: {
      let updatedList = [...state.wishlist];
      const productInWishlist = updatedList.find(
        (product) => product.id === action.payload.id
      );
      if (productInWishlist) {
        return state;
      }
      updatedList = updatedList.concat(action.payload);
      toast.success("Book added to wishlist");
      return {
        ...state,
        wishlist: updatedList,
      };
    }
    case ActionTypes.REMOVE_FROM_WISHLIST: {
      let updatedList = [...state.wishlist];
      const productInWishlist = updatedList.find(
        (product) => product.id === action.payload.id
      );
      if (!productInWishlist) {
        return state;
      }
      updatedList = updatedList.filter(
        (product) => product.id !== action.payload.id
      );
      toast.success("Book removed from wishlist");
      return {
        ...state,
        wishlist: updatedList,
      };
    }
    default:
      return state;
  }
};
  • SET_PRODUCTS sets the products data which is present within data/productsData.js.
  • SORT_BY_PRICE sets the value to either LOW_TO_HIGH or 'HIGH_TO_LOW`
  • ADD_TO_WISHLIST adds a product to the wishlist and shows a success toast message.
  • REMOVE_FROM_WISHLIST removes a product from the wishlist and shows an appropriate toast message.

Decide on how to divide the reducer

From this example, we could easily divide the above-mentioned reducer into 2 - one to handle product-related manipulations and the other to handle the wishlist functionalities.

We are going to do just that ๐ŸŒž

Let us create a new file within the reducers file named productReducer.js. This file would contain the initial state for products as well as the reducer that contains the product manipulations.

Link to CodeSandBox

import { ActionTypes } from "./actions";

export const productState = {
  products: [],
  sortBy: "",
  // could have other properties related to products.
};

export const productsReducer = (state = productState, action) => {
  switch (action.type) {
    case ActionTypes.SET_PRODUCTS: {
      return {
        ...state,
        products: action.payload,
      };
    }
    case ActionTypes.SORT_BY_PRICE: {
      return {
        ...state,
        sortBy: action.payload.value,
      };
    }
    default:
      return state;
  }
};

Similarly, we create another reducer file named wishlistReducer.js.

import { ActionTypes } from "./actions";
import { toast } from "react-toastify";

export const wishlistState = [];

export const wishlistReducer = (state = wishlistState, action) => {
  switch (action.type) {
    case ActionTypes.ADD_TO_WISHLIST: {
      let updatedList = [...state];
      const productInWishlist = updatedList.find(
        (product) => product.id === action.payload.id
      );
      if (productInWishlist) {
        return [...state];
      }
      updatedList = updatedList.concat(action.payload);
      toast.success("Book added to wishlist");
      return [...updatedList];
    }
    case ActionTypes.REMOVE_FROM_WISHLIST: {
      let updatedList = [...state];
      const productInWishlist = updatedList.find(
        (product) => product.id === action.payload.id
      );
      if (!productInWishlist) {
        return [...state];
      }
      updatedList = updatedList.filter(
        (product) => product.id !== action.payload.id
      );
      toast.success("Book removed from wishlist");
      return [...updatedList];
    }
    default:
      return state;
  }
};

We would now modify dataReducer.js as follows:

import { productsReducer, productState } from "./productReducer";
import { wishlistReducer, wishlistState } from "./wishlistReducer";

// add all the initialStates to create a single state.
export const initialState = {
  ...productState,
  wishlist: wishlistState,
};

// combine all the reducers to this updated state
export const reducer = (state, action) => {
  state = {
    ...state,
    ...productsReducer(state.products, action),
    wishlist: wishlistReducer(state.wishlist, action),
  };

  switch (action.type) {
    // switch case to check some common state manipulations - if any
    default:
      return state;
  }
};

By modifying the dataReducer.js as mentioned above, we are good to go! ๐Ÿ’ƒ Yes, we need not make any kind of changes to the other parts of the code. All the hooks and state management would work exactly the same.

Understanding the working

Let's break down and see what happens within dataReducer.js and understand how this magic works.๐Ÿช„

In the first example, our initial state had 3 properties and looked like โฌ‡๏ธ

export const initialState = {
  products: [],
  sortBy: "",
  wishlist: [],
};

We divided them into 2 states as productState and wishlistState

export const productState = {
  products: [],
  sortBy: "",
};

export const wishlistState = [];

Note: Observe how I have used an object for productState and an array in the case of wishlistState. We could modify the individual states as per our needs while dividing the reducer functionalities.

Now, within dataReducer.js, we brought back the same intialState as follows:

export const initialState = {
  ...productState,
  wishlist: wishlistState,
};

We are destructuring productState to get the properties products and sortBy into the same state object as that of wishlist. In the same way, we modify the state within reducer to obtain the latest modified value from productReducer and wishlistReducer respectively.

state = {
  ...productsReducer(state.products, action),
  wishlist: wishlistReducer(state.wishlist, action),
};

We could then add a switch case to this reducer if there are any further state modifications and return the latest state value to the view.

I hope this article helped in understanding how we could break down a single reducer into smaller ones based on individual functionalities and yet maintain a single state value. If this sounds interesting, you could extend this code and implement the cart functionality with the same approach.

Hint: We could have cartState and cartReducer which can be included within initialState and reducer respectively.

Tag me on Twitter and let me know if you were able to add this feature.

If this article was helpful, please give this post a like (with your favorite emoji ๐Ÿ˜). Let me know your thoughts in the comments.

Reach out to me on Twitter if you have any queries. Happy learning! ๐Ÿ’ป

Peace โœŒ