useReducer as the foundation of complex state management in React


React has two primary ways of working with state in your application: useState and useReducer. useState is the most popular, and easiest to work with because its the first state management hook that you learn as a beginner. However, useState can only get you so far.

If you have multiple states that you want to work with in your application, you need to declare several states. Overtime things get complicated real fast, especially if you have degrees of dependency among the states.

To bring order back to the chaos, you don’t need to use external libraries like Redux. Instead, if your state isn’t that complex enough, useReducer and React’s Context API can help get the job done.

Understanding React’s useReducer hook

You can start using useReducer like so:

import { useReducer } from 'react';

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

It takes two mandatory parameters, a reducer function and the initial state. It then returns an array with two important things, the current state state and a dispatch function.

The third argument init (an initializer function that should return the initial state) is optional.

If you want to hard code your state beforehand, you can do it by passing it as the second argument, ignoring the third.

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

If you’re well versed with useState you can see a familiar pattern going on here.

As a reminder, useState takes one single parameter (your initial state) and by calling the hook, you get back an array with the current state together with a state setter function (used to update the state).

Here’s an example:

import { useState } from "react";

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

Similar to useState, useReducer gives you the current state and a function to update the state but with a twist. In useReducer, the reducer function is the one responsible for updating state. But to update the state, you have to “dispatch” an action object using the dispatch function.

The Dispatch Function

If you need to update your state, you must call the dispatch function. Calling dispatch will also trigger a re-render, which is needed to ensure that your UI displays the current state of the component. To ensure your state is updated, the dispatch function takes an action object as the only argument.

The object specifies the action performed by the user. Conventionally, the object contains a type property that matches with a given type specified in the reducer function.

Typically, you call the dispatch function after a user does some state-changing action like clicking on a button or after getting response from an API.

const [state, dispatch] = useReducer(reducer, { age: 20 });

function handleClick() {
  dispatch({ type: "increment_age" });
}

In the example above, we call the dispatch function and pass in an action object with a type of ‘increment_age’. The type property represents what the user did.

Note: By convention, an action is usually an object with a type property identifying it and, optionally, other properties with additional information. ~ React Docs.

After calling dispatch with the user’s action, the function will call our reducer with the specified action object as the argument.

The Reducer Function

According to the official React docs, the reducer specifies how the state gets updated. It takes two parameters, the current state and an action.

function reducer(state, action){
	  switch (action.type) {
    case 'increment_age': {
      return {
        ...state,
        age: state.age + 1
      };
    }
    ...
  }
}

The action object usually has a type property that represents the action dispatched by the dispatch function. You’re free to use an if statement to specify your action types but a switch statement is preferred by community convention.

In the sample code above, we’re watching for an action with increment_age type. Inside the block, we specify how our state will be updated. In the example, we return an object with our current state and modify the age property by adding one.

Using useReducer to Declare and Update State

Away with the theory, let’s get real with a real-life example, to help you get a full picture of how the useReducer hook works and how you can use it in your project.

Let’s say we have an initial state object with two properties, name and age. Using useReducer, let’s declare our initial state:

const [state, dispatch] = useReducer(reducer, { name: "Alvin", age: 100 });

Next, we’ll create the reducer function with logic for updating our age property:

function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT_AGE":
      return {
        ...state,
        age: state.age + 1,
      };

    case "DECREMENT_AGE":
      return {
        ...state,
        age: state.age - 1,
      };

    default:
      // add default case in case the action type is unkown
      break;
  }
}

Assumming we have two buttons displayed on the UI for incrementing and decrementing the age property, like so:

<button onClick={handleAgeIncrement}>Increment age</button>
<button onClick={handleAgeDecrement}>Decrement age</button>

We can call the dispatch function to update the state accordingly when a user clicks on either button by passing in action objects with specific types that match the cases inside our reducer function.

function handleAgeIncrement() {
  dispatch({ type: "INCREMENT_AGE" });
}

function handleAgeDecrement() {
  dispatch({ type: "DECREMENT_AGE" });
}

Now each time we click the increment or decrement button, our state will be instantly updated. That’s how you can use useReducer to manage state in React.

Here’s the full code example:

In case the embed doesn’t load, check it out on codesandbox.

Video walkthrough

https://youtu.be/YB0bjmZAuzU

Why Should You Care about useReducer?

useReducer is specifically important in React because the pattern used to update state is somewhat similar to what you’ll see in Redux, the most popular and robust React state management libraries.

In smaller projects, useReducer might be all you need. But in larger applications, using dedicated state management libraries is highly recommended.