React useReducer Hook: The Ultimate Guide (With Examples)

Divine Orji
June 7, 2022
Try Memberstack for Free

TABLE OF CONTENTS
GET STARTED
Try Memberstack

State management means keeping track of how our data changes over time. In React, we can manage state with hooks or using an external state management library like Redux. In this article, we will explore a hook called useReducer and learn about its capabilities for state management.

Introduction to React useReducer

useReducer is a React Hook that gives us more control over state management than useState, making it easier to manage complex states. Its basic structure is:


const [state, dispatch] = useReducer(reducer, initialState)

Combined with other React Hooks such as useContext, it works almost similarly to Redux. The difference is that Redux creates a global state container (a store), while useReducer creates an independent state container within our component.

React useReducer can be used to manage state that depends on previous states, and efficiently manage multiple, complex states.

Prerequisites

To understand this article, the following are required:

  • Good knowledge of JavaScript and React, with emphasis on functional programming.
  • Experience with some React Hooks such as useState is not strictly required, but preferred.


How does useReducer work?

To understand this, let’s first look at JavaScript’s Array.prototype.reduce() method.

Given an array, the reduce() method executes a reducer callback function on each element in the array and returns a single final value.

The reduce() method takes in two parameters: a reducer function (required) and an initial value (optional). Take a look at this example below:


const numbers = [2,3,5,7,8] // an array of numbers

const reducer = (prev, curr) => prev + curr; // reducer callback function
const initialValue = 5; // initial value

const sumOfNumbers = numbers.reduce(reducer, initialValue); // reduce() method

console.log(sumOfNumbers) 
// prints 30, a sum of all the elements in numbers array and the initial value

On its first iteration, prev takes in the initialValue,  curr takes the current element in the array (the first element in this case), and the function executes with both values. The result is then stored as the new prev, and curr becomes the next element in the array.

If there is no initial value, prev starts with 0.

React useReducer works in a similar way:

  • It accepts a reducer function and an initialState as parameters.
  • Its reducer function accepts a state and an action as parameters.
  • The useReducer returns an array containing the current state returned by the reducer function and a dispatch for passing values to the action parameter.


The reducer function

It is a pure function that accepts state and action as parameters and returns an updated state. Look at its structure below:


const reducer = (state, action) => {
  // logic to update state with value from action
  return updatedState
}

The action parameter helps us define how to change our state. It can be a single value or an object with a label (type) and some data to update the state (payload). It gets its data from useReducer's dispatch function.

We use conditionals (typically a switch statement) to determine what code to execute based on the type of action (action.type).


Understanding useReducer with examples

Example 1: A simple counter


// TODO: Write a component that increments a count when a user clicks a button 

import React, { useReducer } from 'react';

const Counter = () => {  
  // set initial state  
  const initialState = 0;  
  
  // implement reducer function  
  const reducer = (state, action) => {    
  let updatedState = state + action;    
  return updatedState;  
 };  
 
 // write useReducer  
 const [state, dispatch] = useReducer(reducer, initialState);  
 
  // display state in component and trigger dispatch with button  
  return (    
  <div>      
   <h3>Counter</h3>     
   <h1>{state}</h1>    
   <button onClick={() => dispatch(1)}>Increase</button>    
   </div>  
  );
 };
 export default Counter;

In the code above:

  • Our initialState is 0, so when the component is initially displayed on the browser, <h1>{state}</h1> shows 0.
  • When a user clicks on the button, it triggers the dispatch, which sets the value of action to 1, and runs the reducer function.
  • The reducer function runs the block of code inside it, which returns the sum of the current state and the action.
  • Its result is then passed as the new, updated state and displayed on the browser.

Right now, our reducer function only increments the state based on the value in dispatch. What if we wanted our state also to decrement or reset? Let’s modify the reducer function to fit our use case.

Example 2: Counter with extra steps


// TODO: Write a component with buttons that increment a counter by 2, decrement by 1, or reset to 0

import React, { useReducer } from 'react';

const Counter = () => {
  // set initial state
  const initialState = 0;

  // implement reducer function
  const reducer = (state, action) => {
    switch (action.type) {
      case 'add':
        return state + action.payload;
      case 'subtract':
        return state - action.payload;
      case 'reset':
        return initialState;
      default:
        throw new Error();
    }
  };

  // write useReducer
  const [state, dispatch] = useReducer(reducer, initialState);

  // display state in component and trigger dispatch with buttons
  return (
    <div>
      <h3>Counter</h3>
      <h1>state}</h1>
      <button onClick={() > dispatch({ type: 'subtract', payload: 1 })}>
        Decrease
      </button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
      <button onClick={() => dispatch({ type: 'add', payload: 2 })}>
        Increase
      </button>
    </div>
  );
};

export default Counter;

In the code above:

  • The value in each dispatch is an object with type and payload.
  • In our reducer function, we used a switch statement to determine our changes to the state based on a chosen type of action.

For a deeper understanding of how we can use useReducer, let’s take a look at some more complex examples.

Example 3: Smart Home controls

Write state logic for a smart home app that controls appliances like light bulbs, AC, music, television etc.

Here’s how we will structure our code:


import React, /* import useReducer here */ from 'react';

const SmartHome = () => {
  // initial state here

  // reducer function here

  // set up useReducer here

  return (
    // Render UI here
  );
};

export default SmartHome;

Here’s our initial state:


const appliances = [
  { name: 'bulbs', active: false },
  { name: 'air conditioner', active: true },
  { name: 'music', active: true },
  { name: 'television', active: false }
];

Let’s create a reducer to deactivate or activate a chosen appliance:


const reducer = (state, action) => {
  switch (action.type) {
    case 'deactivate':
      return state.map((appliance) => {
        if (appliance.name === action.payload) {
          appliance.active = false;
        }
        return appliance;
      });
    case 'activate':
      return state.map((appliance) => {
        if (appliance.name === action.payload) {
          appliance.active = true;
        }
        return appliance;
      });
    default:
      return state;
  }
};

Now let’s import and set up useReducer to manage our state:


import React, { useReducer } from 'react';

const SmartHome = () => {
  // appliances (our initial state)

  // reducer function

  const [state, dispatch] = useReducer(reducer, appliances);

  return (
    // Render UI here
  );
};

export default SmartHome;

Here, useReducer takes in our initial state (appliances) and our reducer function, and returns the current state, and a dispatch function that we can use to update our reducer’s action.

Let’s render the data in our state on the UI:


return (
  <div className="container">
    <h1>SmartHome</h1>
    <div className="grid">
      {state.map((appliance, idx) => (
        <div key={idx} className="card">
          <h2>{appliance.name}</h2>
          {appliance.active ? (
            <button className="status active">Active</button>
          ) : (
            <button className="status inactive">Not active</button>
          )}
        </div>
      ))}
    </div>
  </div>
);

Here we get the state returned from useReducer, map it and display each appliance name and status (active or inactive). Here’s the result:

Let’s add some functionality to our UI code using dispatch to update the state:


<div key={idx} className="card">
  <h2>{appliance.name}</h2>
    {appliance.active ? (
      <button className="status active"
        onClick={() => dispatch({ type: 'deactivate', payload: appliance.name })}
      >
        Active
      </button>
    ) : (
      <button className="status inactive"
        onClick={() => dispatch({ type: 'activate', payload: appliance.name })}
      <
        Not active
      </button>
    )}
  </div>

Here, when a user clicks on an Active button, it deactivates the appliance and displays the Not active button.

Example 4: Shopping cart

Let’s write some logic to manage the state of a shopping cart with various items in it. We need to give our users the ability to add items to the cart or delete the ones they don’t want.

First let’s set up our initial state:


const initialState = {
  input: '',
  items: [],
}

On a closer look at our initial state, we will see that it has two states inside: input that contains an empty string and items that contains an empty array. Our input will hold whatever the user types in, and items will contain an array of chosen items. This means that we’re handling multiple states.

Next, let’s import and set up useReducer and build out the UI to get cart input from the user:


const [state, dispatch] = useReducer(reducer, initialState);

return (
  <div>
    <h1>Shopping Cart</h1>
    <form onSubmit={handleSubmit}>
      <input type="text" value={state.input} onChange={handleChange} />
    </form>
  </div>
);

Here, we created an input that takes its value from our useReducer's state. Right now, the value is an empty string due to our initialState, but when we update the state, it’ll take in the updated value.

Our handleChange and handleSubmit are going to contain dispatch functions to get and submit the value from our input:


const handleChange = (e) => {
  dispatch({ type: 'input', payload: e.target.value });
}

const handleSubmit = (e) => {
  e.preventDefault();
  dispatch({
    type: 'add',
    payload: {
      id: new Date().getTime(),
      name: state.input
    }
  });
}

In our handleChange function, we have a dispatch function that contains two values:

  • type: 'input', which specifies the type of action.
  • payload, which contains the value gotten from our <input>. We will update our state object with this value.

In handleSubmit, our payload will create a new object with two properties:

  • id: Its value will be gotten from the current date.
  • name: This will contain the value of the input in our current state.

Based on its type, our reducer function will add the content of this payload to the items array in our state.

Let’s write the reducer function to handle all these data:


const reducer = (state, action) => {
  switch (action.type) {
    case 'add':
      let updatedState = {
        ...state,
        items: [...state.items, action.payload]
      };
      state.input = '';
      return updatedState;
    case 'input':
      return {...state, input: action.payload};
    default:
      return state;
  }
}

Let’s also update our UI code to display the items in our state:


return (
  <div>
    <h1>Shopping Cart</h1>
    <form onSubmit={handleSubmit}>
      <input type="text" value={state.input} onChange={handleChange} />
    </form>

    {/* TODO: display items in cart (our current state) */}
    <div>
      {state && state.items.map(item, index) => (
        <div>{index + 1}. {item.name}</div>
      )}
    </div>
  </div>
);

Let’s now give users the ability to delete the items they don’t want anymore.

Update our reducer function:


const reducer = (state, action) => {
  switch (action.type) {
    case 'add':
      let updatedState = {
        ...state,
        items: [...state.items, action.payload]
      };
      state.input = '';
      return updatedState;
    case 'input':
      return {...state, input: action.payload};
    // Let users delete items they don't need anymore:
    case 'delete':
      let filteredState = {
        ...state,
        items: [...state.items].filter((x) => x.id !== action.payload)
      };
      return filteredState;
    default:
      return state;
  }
}

Adding a delete button to the UI:


return (
  <div>
    <h1>Shopping Cart</h1>
    <form onSubmit={handleSubmit}>
      <input type="text" value={state.input} onChange={handleChange} />
    </form>

    {/* TODO: display items in cart (our current state) */}
    <div>
      {state && state.items.map(item, index) => (
        <div>{index + 1}. {item.name}</div>
        <button onClick={() => dispatch({type: 'delete', payload: item.id})}>
          x
        </button>
      )}
    </div>
  </div>
);

And we’re done! Here’s the complete code below:

https://gist.github.com/dpkreativ/25f42715db4da07262c988bf633cd192

https://gist.github.com/dpkreativ/25f42715db4da07262c988bf633cd192

useState vs useReducer

Fundamentally, we can manage our state with either useState or useReducer. As a matter of fact, useState implements useReducer under the hood.

The significant difference between both hooks is that with useReducer, we can avoid passing callbacks through different levels of our component and use the dispatch function instead, improving performance for components with deep updates.

Declaring the state

We declare useState like this:


const [state, setState] = useState(initialState);

In useReducer, it is done like this:


const [state, dispatch] = useReducer(reducer, initialState);

Updating state

In useState, we update our state like this:


<button onClick={() => setState(newState)}>Update</button>

In useReducer, we do it like this:&gt;

The value of newState is then sent to our reducer function, which updates the state.


When to use useReducer Hook

The current state depends on the previous state

In Example 2, we increment or decrement the counter based on the previous value in the state. useReducer gives a predictable state transition when moving from one state to another.

Managing complex states or multiple states

When working with complex states such as nested objects, useReducer helps us determine how the state is updated based on the particular action triggered. In Example 4, its state is an object which contains a parameter (items) that is an array of objects. With useReducer, we assigned action types such as 'add', 'input', and 'delete' to update different parts of the state.

Updating state based on another state

In Example 4, to add a new item to the items array, we need to get the input value from the user first. With useReducer, we get the current input value from our state and use it to create a new object, which we then added to our items array.


When not to use the useReducer Hook

Managing simple state

It would be much quicker to set up useState for cases where we need to handle a very simple state.

Central state management

In a large application with many components depending on the same state, it would be better to use a third party state management like Redux or Mobx, as they centralize our app’s state and logic.


Conclusion

This article demonstrated how to handle complex states with useReducer, exploring its use cases and tradeoffs. It’s important to note that no single React hook can solve all our challenges, and knowing what each hook does can help us decide when to use it.

The examples used in this article are available on CodeSandbox:

https://codesandbox.io/embed/react-usereducer-examples-49ol6g?fontsize=14&hidenavigation=1&theme=dark