Skip to main content

Command Palette

Search for a command to run...

How to use React's useReducer Hook

Updated
14 min read
How to use React's useReducer Hook

React’s useReducer hook is one of the built-in hooks React provides, it is an alternative to the more commonly used useState hook. However, its strict and verbose setup often leads developers to prefer the useState hook or state management libraries. This article aims to teach and simplify the use of the useReducer hook, showing how it can be a game-changer for your project depending on the use case.

In this tutorial, you will learn how to set up the useReducer hook, its use cases, benefits, and drawbacks, along with some basic examples.

If you want to learn more about the useReducer Hook, check it out in the React Docs.

Getting Started

For us to proceed with this article, you need to be familiar with the following:

  • Basic Experience and understanding of React and React Hooks( most importantly, the useState hook)

  • Use your favourite code editor and package manager to follow along

What is the useReducer Hook

The useReducer hook is a React hook used to build custom state logic and an alternative to the useState hook that makes use of a reducer, an initial state, and dispatch functions to update state.

The useReducer hook does not modify the original state directly. It returns and updates a new copy of the state, typically with a switch statement and its case labels and clauses. The useReducer hook follows a more structured implementation, making React projects more manageable and predictable as they grow in complexity.

Structure of the useReducer Hook

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

To accurately modify state with the useReducer hook, it is essential to understand how useReducer is structured. The useReducer Hook consists of the following:

  • State: This is the current state before an update.

  • Dispatch: The dispatch function activates the reducer of the useReducer hook, updating the new copy of the state.

  • Reducer: The reducer is a function that updates the state using a conditional statement, including the action type, case labels, and all case clauses containing the state update functionality.

  • Initial state: This is the default state that the reducer updates.

Setting Up the useReducer Hook

The useReducer hook is often known for its complex setup when building a project, but here’s how it is set up:

Firstly, import the useReducer hook into the App.jsx file of a new React project:

import { useReducer } from "react";

Then, add the following code snippet:

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

You can see that the syntax is similar to that of useState. It also uses a state variable and a dispatch function to update the state, however, useReducer involves a few additional steps.

Creating a Reducer, Initial State, and Action

The reducer in the useReducer hook contains our state logic. It uses a conditional statement to alter the state based on what the action type matches.

A Switch Statement is commonly used in a reducer as a conditional statement because of its more concise implementation.

Create an anonymous function as the first argument for the use of the Reducer hook. Then, add action and state parameters to the anonymous function.

const [state, dispatch] = useReducer((state, action)=>{}, initialState)

Once the reducer is set up, create a switch statement within it and include an action.type object as a parameter in the switch statement.

const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
    }
}, initialState)

Then, add a new case label and case clause to the switch statement using the code below:

const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
// Increment Case Label
        case "increment": {
            return { ...state, count: state.count + 1 };
        }
// Decrement Case Label
        case "decrement": {
            return { ...state, count: state.count - 1 };
        }
    }
}, initialState)

In the code snippet above, we use an increment and decrement case label for the logic of a basic counter in useReducer. This is typically easier with useState, but it’s a simple way for us to explore useReducer.

When the Increment case label matches the action.type property in the counter, it will modify the initial state, which is the number zero and update it. Then, if the Decrement case label matches the action.type property, the initial state is modified, and the number decreases by one(1).

The useReducer Hook takes an immutable approach with state, so the state copy is updated because the case clause in the switch statement returns an object. This object includes a spread ({...state}) of the current state and the count value, which updates the initial state.

case "increment": {
return: {...state, count: state.count + 1 }
},

Initial State

The initial State is the second argument in the useReducer hook, it serves as the default value for the state before it is updated. Since we are working with a counter, the initial state is typically set to zero(0).

const [state, dispatch] = useReducer((state, action) => {
    // Switch Statement Inside an Anonymous Reducer Function
    switch (action.type) {
        case "increment": {
            return {
                ...state,
                count: state.count + 1
            };
        }
        case "decrement": {
            return {
                ...state,
                count: state.count - 1
            };
        }
    };
};
// initial state
}, {
count: 0
});

Action

The action parameter describes the state change. The reducer then reads the action and returns a new state according to what is in the action.

In the useReducer Hook, the action typically takes in a type property to tell the reducer what kind of state update to perform.

const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
        case "increment": {
            return {
                ...state,
                count: state.count + 1
            };
        }
        case "decrement": {
            return {
                ...state,
                count: state.count - 1
            };
        }
    };
}
}, {
count: 0
})

However, it can be extended by adding an extra payload property, which can introduce new values to the state update. We will explore this later in the article.

Now, add the following JSX elements to complete the counter:

  <main className="h-[100vh] w-full bg-black text-white flex justify-center items-center">
    <section className="h-fit w-full flex flex-col justify-center items-center gap-[12px]">
        <h1 className="text-[3.5rem] font-pixel font-bold">{state.count}</h1>
        <section className="flex gap-[12px] [&>button]:bg-[#222] [&>button]:py-[8px] [&>button]:px-[22px] [&>button]:text-[1.4rem] [&>button]:rounded-md [&>button]:text-center [&>button]:font-bold [&>button]:cursor-pointer">
            <button>+</button>
            <button>-</button>
        </section>
    </section>
</main>;

The paragraph element contains the count from the current state of the useReducer hook.

Next, add the dispatch function to the buttons as click events. One button will have the type set to increment, and the other to decrement.

  <section className="flex gap-[12px] [&>button]:bg-[#222] [&>button]:py-[8px] [&>button]:px-[22px] [&>button]:text-[1.4rem] [&>button]:rounded-md [&>button]:text-center [&>button]:font-bold [&>button]:cursor-pointer">
     <button onClick={()=> dispatch({ type: "increment" })}>+</button>
     <button onClick={()=> dispatch({ type: "decrement" })}>-</button>
 </section>
 </section>

Now that the code is complete, as you click on the buttons in the counter, the new copy of the state is updated, and the number is either increased by one(1) or decreased by one(1).

Here is the link to the Counter project in case you would like to test it out. If you also want to view the project source code, here’s the GitHub link.

Other Basic Examples Using the useReducer Hook

In the previous section, you learned how to set up the useReducer hook along with a basic counter to showcase the useReducer hook in action. In this section of the article, we will dive into other examples such as:

Toggle in useReducer

Here, we build a toggle with the useReducer Hook to see how useReducer works with conditions. To begin, create a new React Project and import the useReducer hook into the App.jsx file.

In this example, set the initial state to a boolean value called toggle. The case clauses in the switch statement will handle toggling this boolean value to true, false, or the opposite of its previous state in the reducer.

import { useReducer } from "react";
import "./App.css";

function App() {
    const [state, dispatch] = useReducer(
        (state, action) => {
            switch (action.type) {
            }
        },
        { toggle: true }
    );
    return <main></main>;
}
export default App;

The case labels and clauses within the switch statement for the toggle include:

open: This case clause modifies the copy of the state by changing the state to true.

case "open": {
    return {
        ...state,
        toggle: true
    }
}

close: This case clause modifies the copy of the state by changing the state to false.

case "close": {
    return {
        ...state,
        toggle: false
    }
}

open-and-close: This case clause changes the state to the opposite of its previous value, either true or false.

case "open-and-close": {
    return {
        ...state,
        toggle: !state.toggle
    }
}

Next, create some JSX elements that include the box and buttons

  <main className="h-[100vh] w-full bg-black flex justify-center items-center">
    <section className="flex flex-col gap-y-[36px]">
        {state.toggle && (
        <div className="h-[200px] w-[200px] bg-purple-600 rounded-md"></div>
        )}
        <section>
            <button>Open</button>
            <button>Close</button>
            <button>Open/Close</button>
        </section>
    </section>
</main>

Add buttons in the JSX elements to handle the three case labels. Each button should have a click event that triggers its corresponding dispatch function.

      <section className="flex flex-col gap-[16px] [&>button]:bg-[#f1f1f1] [&>button]:text-black [&>button]:font-bold [&>button]:text-[1.3rem] [&>button]:py-[6px] [&>button]:px-[8px] [&>button]:rounded-md [&>button]:cursor-pointer">
          <button onClick={()=> dispatch({ type: "open" })}>Open</button>
          <button onClick={()=> dispatch({ type: "close" })}>Close</button>
          <button onClick={()=> dispatch({ type: "open-and-close" })}> Open/Close </button>
      </section>

The state will then conditionally render a box that will either be visible or hidden based on the state's boolean value:

 {state.toggle && (
<div className="h-[200px] w-[200px] bg-purple-600 rounded-md"></div>
)}

Here is the link to the Toggle project in case you want to test it out. Here’s the GitHub link to view the project’s source code.

Basic To-do List with the useReducer Hook

Building a basic to-do list is a good way to demonstrate the power of using the useReducer hook in a React project. In this project, we will store the created to-dos and manage states(list of to-dos) and payloads in our project.

Before we start, you need to know the features we will add to the to-do list. These include creating a new todo, completing a todo, and deleting todos.

In a new Blank React project, import the useReducer hook and add a new instance of the useReducer hook to your project in the new App.jsx file with the following code:

const [state, dispatch] = useReducer(
    (state, action) => {
        const { type, payload } = action;
        switch (type) {
            case "handle-todo": {
                return { ...state, todo: payload };
            }

            case "new-todo": {
                return {
                    ...state,
                    myTodos: [
                        ...state.myTodos,
                        {
                            id:
                                state.myTodos.length === 0
                                    ? (state.id = 0)
                                    : state.id++,
                            todo: state.todo,

                            complete: false,
                        },
                    ],
                };
            }
            case "complete-todo": {
                return {
                    ...state,
                    myTodos: state.myTodos.map((todo) => {
                        if (todo.id === payload) {
                            return { ...todo, complete: !todo.complete };
                        }
                    }),
                };
            }
            case "remove-todo": {
                return {
                    ...state,
                    myTodos: state.myTodos.filter(
                        (todo) => todo.id !== payload
                    ),
                };
            }

            default: {
                return state;
            }
        }
    },

    {
        id: 0,
        todo: "",
        myTodos: [],
    }
);

Here’s how the code snippet above works:

The Reducer takes in a type and payload property from the action parameter:

const { type, payload } = action;

We destructured the type and payload properties from the action parameter because it's a better practice when your action has payloads, making the code more concise.

The initial state in the snippet includes the ID, the Todo property, and the myTodos array. The id property is set to zero by default, the todo property is an empty string, and myTodos is an empty array.


    {
      id: 0,
      todo: "",
      myTodos: [],
    }

Next, the switch statement in the reducer includes the following case labels and clauses:

  • The handle-todo case clause saves user input to the todo property using the payload property, which is assigned to the input element's value.
case "handle-todo": {
    return {
        ...state,
        todo: payload
    };
}

This creates a copy of the todo state by spreading the previous state, ensuring that when we add more characters, the previous input isn't cleared or overwritten.

  • The new-todo case clause handles creating new todos in our project. It does this by spreading the previous state and adding the new todo to the myTodos array, along with the new todos.
case "new-todo": {
    return {
        ...state,
        myTodos: [...state.myTodos, {
            id: state.myTodos.length === 0 ? (state.id = 0) : state.id++,
            todo: state.todo,
            complete: false,
        }, ],
    };
}

The initial state of the ID is updated in this case clause and is conditionally rendered. It sets the id property to zero if the myTodos array length is zero, or increases it by one if the myTodos array has more than zero items.

  • The complete-todo case clause is responsible for marking a task as complete. It updates the state by mapping over the myTodos array, checking for the id of the todo where the done button is clicked, and setting the complete property to true. If the id does not match, it returns the todo without changing the complete property.
case "complete-todo": {
    return {
        ...state,
        myTodos: state.myTodos.map((todo) => {
            if (payload === todo.id) {
                return {
                    ...todo,
                    complete: !todo.complete
                };
            } else {
                return todo;
            }
        }),
    };
}
  • The remove-todo case clause removes the todo from the myTodos array based on the ID of the todo.
case "remove-todo": {
    return {
        ...state,
        myTodos: state.myTodos.filter((todo) => todo.id !== payload),
    };
}
default: {
    return state;
}
}
},

The default case clause returns the current state if none of the cases match

To display any created todo in the browser for our project, create the necessary JSX elements and assign them to the myTodos array:

<main className="h-fit w-full">
    <section className="h-fit p-[22px] flex gap-[22px]">
        <input className="border-[1.2px] border-black rounded-md px-2" type="text" onChange={(e)=> dispatch({ type: "handle-todo", payload: e.target.value, }) } placeholder="Type Todo Here..." /> <button onClick={()=> dispatch({ type: "new-todo", }) } className="self-end p-[8px 12px] bg-[#222] text-white p-2 rounded-md cursor-pointer" > Add </button>
    </section>
    <section className="h-fit w-[90%] mt-[32px] p-[22px] flex flex-col gap-[32px]">
        <h3 className="underline font-bold text-[1.6rem]">Todos</h3> {state.myTodos.map((todo) => ( <section className="h-fit w-full p-2 flex gap-2 justify-between border-[1.2px] border-black rounded-md" key={todo.id}>
            <h3 style={{
                textDecoration: todo.complete ? "line-through" : "none",
                color: todo.complete ? "grey" : "black",
              }} className="flex flex-col gap-2"> {todo?.todo} </h3>
            <div className="flex gap-4 text-white [&>button]:p-2 [&>button]:rounded-md [&>button]:cursor-pointer">
                <button className="bg-[#222]"> Done </button>
                <button className="bg-red-400"> Delete </button>
            </div>
        </section> ))}
    </section>
</main>

In the code snippet above, we use the myTodos array with JSX elements to show each todo, along with a done and delete button. These buttons perform actions based on the ID of each element in the mapped array.

We added a header element to represent each todo and link it to the todo property from the mapped myTodos array.

     <h3 style={{
        textDecoration: todo.complete ? "line-through" : "none",
        color: todo.complete ? "grey" : "black",
      }} className="flex flex-col gap-2"></h3>

The header element includes inline conditional styling that changes when the complete button is toggled.

Adding Payloads

Since we added the payload property to the reducer, the dispatch function in our JSX elements also needs to include the payload, just like we did earlier for the input element.

Add the following type in the dispatch function for the new-todo button:

 <button
      onClick={() =>
        dispatch({
          type: "new-todo",
        })
      }
      className="self-end p-[8px 12px] bg-[#222] text-white p-2 rounded-md cursor-pointer"
    >
      Add
    </button>

Then, add the payload, which is the todo ID, to the dispatch functions for the done and delete buttons.

   <section className="flex gap-4 text-white [&>button]:p-2 [&>button]:rounded-md [&>button]:cursor-pointer">
      <button
        className="bg-[#222]"
        onClick={() => dispatch({ type: "complete-todo", payload: todo.id })}
      >
        Done
      </button>
      <button
        className="bg-red-400"
        onClick={() => dispatch({ type: "remove-todo", payload: todo.id })}
      >
        Delete
      </button>
    </section>

Now, when you test out the to-do list, it works seamlessly:

Here is the link to the Todo-List project if you want to test it out. If you also want to view the project source code, here’s the GitHub link.

Benefits of the useReducer Hook

When applied correctly or in the right scenarios, useReducer can be a valuable asset to your React project. Some of the advantages that useReducer offers include:

More Predictable State: All state logic is created and updated through the reducer function before the dispatch function triggers specific state changes. This makes the state logic easy to trace and more predictable.

Centralized State Logic: useReducer’s centralized structure keeps all state logic in one place, making it easy to create and modify state according to your project's goals.

Scalable: Because useReducer's state logic is separate from the UI logic, adding new state logic is easier, and it can be easily removed if requirements change.

Great for Complex State: For projects with complex state logic that spans across components or files, useReducer makes it easier to manage and debug.

Drawbacks of the useReducer Hook

Despite its great benefits, here are some areas where it falls short:

More Boilerplate Code: Setting up a reducer function, actions, payloads, and case clauses takes more lines of code compared to useState.

Learning Curve: If not structured properly, handling state with useReducer becomes harder to understand and a hassle to work with, especially for beginners.

Overkill for Simple State: For simple state logic involving single values or basic state updates, useState is more appropriate and suitable than useReducer.

Conclusion

The useReducer hook creates and updates state logic like useState, but in a more structured way. As you've learned from this article, useReducer is particularly useful in specific situations, especially in projects with more complex state logic.

In this tutorial, we learned how to set up the useReducer hook, create a reducer, write code that changes state logic using the dispatch function, and more. We also explored some basic examples to improve our understanding.

If you found this article helpful, please like and comment. Thank you!

Resources