Understanding React’s useReducer Hook

The useReducer React hook is a powerful tool for managing complex states in a React application. It allows developers to handle state updates in a more organized and predictable way and can be especially useful in situations where the state is deeply nested or has multiple dependencies.

In this article, we will explore the useReducer hook, how it works, when it is best used, and how to effectively use the useReducer hook to make your code more efficient. Whether you're a seasoned React developer or just getting started, understanding the useReducer hook can be a valuable addition to your toolkit. So let’s delve right into it 👇

Prerequisites

To follow along in this article, you need to have the following;

  • Basic knowledge of Reactjs

  • A basic understanding of JavaScript

What’s the useReducer Hook?

According to the official React documentation, useReducer is a react hook that lets you add a reducer to your React component. It allows you to use a reducer function to manage the state of your component.

There are a couple of misconceptions about the reducer function; some people think it’s a redux thing or just a function with a switch statement. While there’s some truth to these, a reducer function is much more than those. The concept of a reducer in React comes from the Array.reduce() javascript API, which takes a reducer callback function as an argument. See an example below:

[1,2,3,4,5].reduce((acc, value) => acc += value, 0) //results in 15.

The reducer callback function takes an accumulated value and the current value and then performs an operation on each element in the array. As seen in the code snippet above, it adds the resulting value of each operation to the accumulated value and eventually returns the accumulated value. This is very similar to how the reducer function of the useReducer hook works. You can create an API that returns the next state by evaluating the current state and an action object with the .reduce() API.

Syntax

The useReducer takes three arguments and returns an array of the current state and the dispatch function to trigger a state update.

const [state, dispatch] = useReducer(reducerFn, initialState, initFn?)

Where;

  • state: is the current state returned by the reducer function. Let's consider the state of a cart, for example;

      const cart = [];
    
  • dispatch: is a function that takes the action as an argument. The action is a regular javascript object. Conventionally it will have a type property which is usually a string describing what “action” the user has just performed. Note that the action argument passed to the dispatch method becomes accessible as the second parameter of the reducerFn.

      const action = { type: "ADD", id:"xyz", quantity: 1}
      /** To dispatch this action */
      dispatch(action)
    
  • reducerFn: is the reducer function, it has access to the current state as its first parameter, and whatever is passed as an argument to the dispatch function call as the second parameter. It returns the next state for the component.

  • initialState: is used as the initial state of the component if an initializer function (initFn) is not provided.

      const reducerFn = (currentState, action) => {
          console.log(currentState) //will log [] to the console
          console.log(action) //will log {type: "ADD", id:"xyz", quantity: 1}
          /** Note that this function much return the next state*/
          if(action.type === "ADD"){
              return  [
              ...currentState, 
              {
                  id: action.id,
                  quantity:action.quantity
              }
              ]
           }
    
          return currentState
      }
    

    Note that a reducer function doesn’t have to use a switch statement; it works with or without it. The reducer function only has to consider the current state, and the action dispatched to evaluate and return the next state of the component.

  • initFn: This is the initializer function. It has a parameter which is whatever is passed as the second argument in calling the useReducer hook(the intialState). It returns what the initial state should be. It’s an optional argument, so if it’s not provided, initialState is used as the initial state. This function is useful, especially if your initial state is being computed by a potentially “expensive” operation, using the initFn ensures that it is not recomputed on every render.

Example usage of the hook

To grasp the concept of the useReducer hook, we will observe a simple example. A simple shopping cart where it’s only possible to add an item, delete an item and update the quantity of an item in the cart. Let’s also assume that the cart only knows the id and quantity of each item.

Step 1: Define the reducer function. We now know that the reducer function is a function with the current state and the action object as parameters and is required to return the next state of the application based on those. We can define it as follows;

const reducerFn = (state, action) => {
    if(action.type === "ADD"){
        return [
            ...state, 
            {
                id: action.id,
                quantity: action.quantity
            }
        ]
    }else if(action.type === "DELETE"){
        return state.filter(item => item.id !== action.id)
    }else if (action.type === "CHANGE_QUANTITY"){
         return state.map(item => item.id === action.id ?                {...item,quantity: action.quantity} : item)
     }else{
          throw Error(`${action.type} action doesn't exist!`)
     }
}

Note that we have not used a switch statement in the above reducer function. This shows that even though it’s conventional and can be cleaner to use a switch statement, you don’t have to, and you can decide on a case-to-case basis if using a switch state is necessary or not.

Step 2: Define the useReducer hook in the component, after importing it from react

import {useReducer} from 'react';
function Product(){
    const [state, dispatch] = useReducer(reducerFn, []);
}

Step 3: Call the dispatch function with an action object describing the action or event that has occurred, to handle updates to state.

function Product(){
const [state, dispatch] = useReducer(reducerFn, []);
/** This handles clicks on the add to cart button */
    const hanldeAddToCart(){
/** This will call the reduceFn with the action object injected into it as the second parameter*/
    dispatch({
        type: "ADD",
        id:"1",
        quantity: 1
     })
}

/** This handles clicks on the delete item button */
    const hanldeDeleteCartItem(){
/** This will call the reduceFn with the action object injected into it as the second parameter*/
    dispatch({
        type: "DELETE",
        id:"1"
     })
}

/** This handles changes to the change qty select element*/
    const hanldeChangeQty(){
/** This will call the reduceFn with the action object injected into it as the second parameter*/
        dispatch({
        type: "CHANGE_QUANTITY",
        id:"1",
        quantity: 2
        })
    }
}

useReducer vs useState?

It’s not strange to wonder “why?” at this point; I mean, we can do these things with the useState hook. In fact, you probably have already solved this same problem in the past using the useState hook, so what’s the catch? Well, there is no strict rule regarding if to useState vs. useReducer. It’s important to consider the complexity of the state being handled and how the state changes. Some things to consider include;

  1. The complexity of the state and its changes: The useState hook has a simpler API and less boilerplate code involved for simple cases. For example, in a case where the state changes are simple, like changing the theme of the user interface from light to dark mode and back, writing a reducer function would complicate things. On the other hand for a more complex state that can change in many different ways, it’s advisable to make use of the useReducer hook to make things easier to manage, as you’ll be able to clearly define the different types of actions that can cause a change and how each of them will be handled.

  2. Multiple dependencies: Are elements of state depending on each other? If you have a situation where one element of state depends on another element of state to get its value, it may be cleaner to have a reducer function handle the state changes, this way everything is handled in one place reducing the chances of having a hidden bug.

At the end of the day, these tips should be considered on a case-by-case basis, really think about what the code/state is like and which of these tools will help get the job done in the best way for you.

Rules to remember when using useReducer

  1. Make sure you are not mutating state in your reducer function, you should ensure that you are always replacing arrays and objects in state rather than mutating them. Like below;

    DON’T

     const reducerFn = (state, action) => {
             switch(action.type){
                 case "ADD":
                     state.push({id:action.id, quantity:action.quantity});
                 return state
                 default:
                     throw Error(`${action.type} action doesn't exist!`)
             }
     }
    

    DO

     const reducerFn = (state, action) => {
         switch(action.type){
             case "ADD":
                 return [...state, {id:action.id, quantity: action.qunatity}];
             default:
               throw Error(`${action.type} action doesn't exist!`)
         }
     }
    

    When an object or array (reference types) state is mutated directly, react ignores the update to state and this can lead to bugs in your user interface. If you must do this, you can consider using the use-immer library, which provides a useImmerReducer that allows you to mutate state. Under the hood, it creates a copy for you and ensures react doesn’t ignore updates to state.

  2. Make sure you are not forgetting relevant elements of the state that are unaffected while replacing state. Because we have to make sure state is not being mutated, it’s easy to forget some element of state when returning the replacement. For example;

    DON’T

     const reducerFn = (state, action) => {
             switch(action.type){
                 case "ADD":
                     return {id:action.id, quantity:action.quantity}
                 default:
                     throw Error(`${action.type} action doesn't exist!`)
             }
     }
    

    In the above, we are returning a single cart item as the cart state, rather than an array of cart items. Always check to see that state is being replaced correctly.

  3. Don’t forget to add a return statement when returning the state for each action type as not doing this will lead to bugs in your code.

    DON’T

     const reducerFn = (state, action) => {
             switch(action.type){
                 case "ADD":
                     const newState = [...state, {id:action.id, quantity: action.id}]
    
                 default:
                     throw Error(`${action.type} action doesn't exist!`)
             }
     }
    
  4. Reducers must be pure, they must not be performing any side effect operation like server calls.

  5. Each dispatch should represent a single event performed by a user even if the event updates more than one element of state. For instance, if we wanted to reset state, rather than call several dispatches that update the various elements, we can have one action type (For example “RESET”) that just updates everything at once. This way it’s easier to debug by logging in your reducer function and things will be generally clearer.

Summary

This article explores the use of the useReducer hook in React, which provides a powerful way to manage state in a component by leveraging the concepts of JavaScript's Array.reduce API. With useReducer, you can define a reducer function that takes the current state and an action object, and computes the next state based on the action. The action object is a JavaScript object that describes changes made to state, and is passed as an argument to the dispatch function. Compared to the useState hook, useReducer is better suited for complex state management, especially when the value of state elements depends on other state elements. For further reading, I encourage you to look in the reference section below.

References