React.js: Why you might need useReducer

Menard Maranan - Jul 5 '23 - - Dev Community

Hooks in React.js is at the core of building functional components. Among the most used are the state management hooks, or simply the state hooks.

In React, two hooks allow you to manage the state:

  • useState - Gives you a state variable and a function to change the value of the state
  • useReducer - Gives you a state variable and a dispatch function for updating the state (via another function called a reducer function).

Both hooks do the same thing, "to manage state", and what you can do with useState can also be done with useReducer and vice versa.

So how do they differ?

The useState hook is the simpler one and is more commonly used. Meanwhile, useReducer (which is based on Redux - a state management library) is often mocked to have tons of boilerplate code.

But treating these hooks this way doesn't give useReducer justice.

There are use cases why useReducer makes a better option than useState.

In this article, I will discuss why you might want to consider using useReducer in some cases over the useState hook.

Organizing the logic for changing the state

When managing a complex component state, your update logic might look messy, especially if you have different ways on changing the state.

I'll give you a simple example - a counter component. Let's say you need to build this component with the following requirements:

  • the component should have a state for tracking the count
  • you should be able to increment, decrement, as well as reset the count.
  • when the count value is 10 or more, the increment and decrement value must be 10.
  • when the count value is -10 or less, the increment and decrement value must be 5.
  • when the count value is between -9 and 9 (inclusive), the increment and decrement value is just 1
  • Resetting the state reverts the count value to 0

So in this example component, we're required to have 3 update logics to the state (increment, decrement, reset) where both increment and decrement update logics have 3 different conditions.

With useState, you might do it this way:

import { useState } from "react";

function Counter() {
    const [count, setCount] = useState(0);


    // The count update logics:
    // handleIncrement, handleDecrement, and handleReset

    const handleIncrement = () => {
        let incrementValue: number;

        if (count >= 10) {
            incrementValue = 10;
        } else if (count <= -10) {
            incrementValue = 5;
        } else {
            incrementValue = 1;
        }

        setCount(prevCount => prevCount + incrementValue);
    }

    const handleDecrement = () => {
        let decrementValue: number;

        if (count >= 10) {
            decrementValue = 10;
        } else if (count <= -10) {
            decrementValue = 5;
        } else {
            decrementValue = 1;
        }

        setCount(prevCount => prevCount - decrementValue)
    }

    const handleReset = () => {
        setCount(0);
    }


    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={handleIncrement}> + </button>
            <button onClick={handleDecrement}> - </button>
            <button onClick={handleReset}>Reset</button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

So in this simple component, you'll see how convoluted the component can get because of those update logics. You may reason out that you can simplify the state update logic by refactoring those three separate functions into one function that accepts a parameter to tell whether you want to increment, decrement, or reset. Sure that works, but how does that differ from creating a reducer function?

And this is why you might consider using useReducer. With useReducer, you can work with complex state management in a more organized way. The main reason why useReducer would be a great choice is that you have a complex state where you have different update logics, and you want to keep things organized. This makes it easier to debug the update logics later (or update them when the requirements change).

Alright, let's rewrite the same component but let's use the useReducer hook here. Let's put the reducer logic in another file and just import it on the component.

// counterReducer.ts

interface CounterAction {
    type: "increment" | "decrement" | "reset";
}

export interface CounterState {
    count: number;
}

export function counterReducer(state: CounterState, action: CounterAction) {
    const changeValue = state.count >= 10
                            ? 10
                            : state.count <= -10 ? 5 : 1;

    switch (action.type) {
        case "increment":
            return { ...state, count: state.count + changeValue };
        case "decrement":
            return { ...state, count: state.count - changeValue };
        case "reset":
            return { count: 0 };
        default:
            const _impossible: never = action.type;
            throw new Error(`Invalid action type: ${action.type}`);
    }
}

Enter fullscreen mode Exit fullscreen mode

Now, let's import this in the counter component:

// Counter.tsx

import { useReducer } from "react";
import { counterReducer, CounterState } from "./counterReducer"

const initialState: CounterState = { count: 0 };

function Counter() {
    const [state, dispatch] = useReducer(counterReducer, initialState);

    return (
        <div>
            <h1>Count: {state.count}</h1>
            <button onClick={() => dispatch({ type: "increment" })}> + </button>
            <button onClick={() => dispatch({ type: "decrement" })}> - </button>
            <button onClick={() => dispatch({ type: "reset" })}> Reset </button>
        </div>
    )
}

Enter fullscreen mode Exit fullscreen mode

So in this case, the reducer function handles all the update logic to the state. This way, you can abstract away from the nitty-gritty details of the state update logic and just specify in the component which update logic you want to take effect. And since we separated the reducer logic in another file, the Counter component here no longer needs to concern itself with the update logic, making things more organized. If you need to debug the update logic, it's easier to know where to look. When requirements change and you want to update these state update logic, it's easier to go to what you're looking for and make changes.

Now you might argue that this makes more boilerplate code (especially because I used TypeScript which adds more boilerplate), but eliminating boilerplate code is not the goal of useReducer (because it's definitely more verbose). Rather, you should use useReducer if you have to manage a complex state and you want the state update logic organized. And as mentioned earlier, organizing your state update logic this way makes it easier to debug and update later when requirements change.

Final thoughts

Hopefully, I conveyed to you the importance of useReducer and why you might want to use it in some cases over useState.

For further reading, I suggest you to look at extracting state logic into a reducer from React documentation. There's also a challenge at the end to help you better understand useReducer.

. . . . . . . . . . . . . . . . . . .
Terabox Video Player