React Hooks: useReducer (with Comprehensive Examples)

React Hooks: useReducer (with Comprehensive Examples)

Hey there 🖐
If you've ever found yourself juggling multiple pieces of state in your component and thought, "There has to be a better way to manage this!"—well, you’re in the right place. Today, we’re going to dive into useReducer, a cool hook that can save you from a lot of state management headaches.

What is useReducer?

useReducer is a hook that lets you manage complex state in your application by adding a reducer.
Usually when managing state in your component, the useState hook is the first hook that comes to mind. It’s simple and straightforward: create state variable, update state variable, update logic or re-render UI in response to those state changes.
For instance, we have a component called Cart, that lets you add items to your cart and display them.

import React, { useState } from 'react';

function Cart() {
  const [cart, setCart] = useState([]);

  const addItemToCart = (item) => {
    setCart([...cart, item]);
  };

  const removeItemFromCart = (id) => {
    setCart(cart.filter(item => item.id !== id));
  };

  return (
    <div>
      <h1>Cart</h1>

      <div>
        <h2>Cart</h2>
        {cart.map(item => (
          <div key={item.id}>
            <p>{item.name} <button onClick={() => removeItemFromCart(item.id)}>Remove</button></p>
          </div>
        ))}
        <button onClick={() => addItemToCart({ id: Date.now(), name: 'Item', qty: 1 })}>Add Item</button>
      </div>
    </div>
  );
}

export default Cart;

Now imagine if you also have to manage the shipping address, the user’s payment method, and also their order review. It becomes way more complex, you would have to keep track of multiple pieces of state for the different actions you want to handle. We would have multiple useState calls for managing different pieces of state (cart, shippingAddress), even though they are related to the same "concept" (shopping cart). Updating the cart is a bit of a hassle. We have to use setCart to update the cart, and the cart’s state is being spread (...cart) and manipulated inside the addItem and removeItem functions. This can lead to bugs because React state updates are asynchronous, and you might end up with stale state or inconsistent updates if you're not careful.

Also, as you add more actions, you’ll keep adding more useState hooks, making the component harder to maintain while also having to write additional functions for each action.

import React, { useState } from 'react';

function Cart() {
  const [cart, setCart] = useState([]);
  const [shippingAddress, setShippingAddress] = useState("");
  const [paymentMethod, setPaymentMethod] = useState("");
  const [orderReview, setOrderReview] = useState(false);

  const addItemToCart = (item) => {
    setCart([...cart, item]);
  };

  const editCartItemQuantity = (id, type = "increase") => {
    const updatedCart = cart.map((item) =>
      item.id === id
        ? {
            ...item,
            qty:
              type === "increase"
                ? item.qty + 1
                : item.qty > 0
                ? item.qty - 1
                : item.qty,
          }
        : item
    );
    setCart(updatedCart);
  };

  const removeItemFromCart = (id) => {
    setCart(cart.filter((item) => item.id !== id));
  };

  const handleAddressChange = (e) => {
    setShippingAddress(e.target.value);
  };

  const handlePaymentChange = (e) => {
    setPaymentMethod(e.target.value);
  };

  const toggleOrderReview = () => {
    setOrderReview((prev) => !prev);
  };

  return (
    <div>
      <h1>Cart</h1>

      <div>
        <h2>Cart</h2>
        {cart.map((item, id) => (
          <div key={item.id}>
            <p>
              {item.name + " " + item.id}
              <button onClick={() => removeItemFromCart(item.id)}>
                Remove
              </button>
              <button onClick={() => editCartItemQuantity(item.id, "increase")}>
                +
              </button>
              <button>{item.qty}</button>
              <button onClick={() => editCartItemQuantity(item.id, "decrease")}>
                -
              </button>
            </p>
          </div>
        ))}
        <button
          onClick={() =>
            addItemToCart({ id: Date.now(), name: "Item", qty: 1 })
          }
        >
          Add Item
        </button>
      </div>

      <div>
        <h2>Shipping Address</h2>
        <input
          type="text"
          value={shippingAddress}
          onChange={handleAddressChange}
          placeholder="Enter shipping address"
        />
      </div>

      <div>
        <h2>Payment Method</h2>
        <input
          type="text"
          value={paymentMethod}
          onChange={handlePaymentChange}
          placeholder="Enter payment method"
        />
      </div>

      <div>
        <h2>Order Review</h2>
        <button onClick={toggleOrderReview}>
          {orderReview ? "Unreview" : "Review"} Order
        </button>
        {orderReview && (
          <p>
            Order Summary: Cart - {cart.length} items, Shipping -{" "}
            {shippingAddress}, Payment - {paymentMethod}
          </p>
        )}
      </div>

      <div>
        <button onClick={() => alert("Order Submitted")}>Submit Order</button>
      </div>
    </div>
  );
}

export default Cart;

A solution to this is the useReducer hook. It is a more "elegant" version of useState for managing complex state transitions. Instead of what we have up there, we’ll consolidate all of the state logic into a single reducer, making it much easier to scale and maintain.

import React, { useReducer } from 'react';

// Initial state for the checkout process
const initialState = {
  cart: [],
  shippingAddress: '',
  paymentMethod: '',
  orderReview: false,
};

// Reducer function to handle all state updates
function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, cart: [...state.cart, action.item] };
    case "EDIT_ITEM_QTY":
      const updatedCart = state.cart.map((item) =>
        item.id === action.data.id
          ? {
              ...item,
              qty:
                action.data.type === "increase"
                  ? item.qty + 1
                  : item.qty > 0
                  ? item.qty - 1
                  : item.qty,
            }
          : item
      );

      return { ...state, cart: updatedCart };
    case "REMOVE_ITEM":
      return {
        ...state,
        cart: state.cart.filter((item) => item.id !== action.id),
      };
    case "SET_SHIPPING_ADDRESS":
      return { ...state, shippingAddress: action.address };
    case "SET_PAYMENT_METHOD":
      return { ...state, paymentMethod: action.paymentMethod };
    case "TOGGLE_ORDER_REVIEW":
      return { ...state, orderReview: !state.orderReview };
    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItemToCart = (item) => {
    dispatch({ type: "ADD_ITEM", item });
  };

  const editCartItemQuantity = (id, type = "increase") => {
    const data = { id, type };
    dispatch({ type: "EDIT_ITEM_QTY", data });
  };

  const removeItemFromCart = (id) => {
    dispatch({ type: "REMOVE_ITEM", id });
  };

  const handleAddressChange = (e) => {
    dispatch({ type: "SET_SHIPPING_ADDRESS", address: e.target.value });
  };

  const handlePaymentChange = (e) => {
    dispatch({ type: "SET_PAYMENT_METHOD", paymentMethod: e.target.value });
  };

  const toggleOrderReview = () => {
    dispatch({ type: "TOGGLE_ORDER_REVIEW" });
  };

  return (
    <div>
      <h1>Cart</h1>

      <div>
        <h2>Cart</h2>
        {state.cart.map((item, id) => (
          <div key={item.id}>
            <p>
              {item.name + " " + item.id}
              <button onClick={() => removeItemFromCart(item.id)}>
                Remove
              </button>
              <button onClick={() => editCartItemQuantity(item.id, "increase")}>
                +
              </button>
              <button>{item.qty}</button>
              <button onClick={() => editCartItemQuantity(item.id, "decrease")}>
                -
              </button>
            </p>
          </div>
        ))}
        <button
          onClick={() =>
            addItemToCart({ id: Date.now(), name: "Item", qty: 1 })
          }
        >
          Add Item
        </button>
      </div>

      <div>
        <h2>Shipping Address</h2>
        <input
          type="text"
          value={state.shippingAddress}
          onChange={handleAddressChange}
          placeholder="Enter shipping address"
        />
      </div>

      <div>
        <h2>Payment Method</h2>
        <input
          type="text"
          value={state.paymentMethod}
          onChange={handlePaymentChange}
          placeholder="Enter payment method"
        />
      </div>

      <div>
        <h2>Order Review</h2>
        <button onClick={toggleOrderReview}>
          {state.orderReview ? "Unreview" : "Review"} Order
        </button>
        {state.orderReview && (
          <p>
            Order Summary: Cart - {state.cart.length} items, Shipping -{" "}
            {state.shippingAddress}, Payment - {state.paymentMethod}
          </p>
        )}
      </div>

      <div>
        <button onClick={() => alert("Order Submitted")}>Submit Order</button>
      </div>
    </div>
  );
}

export default Cart;

Whoa, I realize I've jumped ahead a bit. You are probably wondering how the useReducer hook works.

Like I mentioned earlier, the useReducer hook is a React Hook that lets you add a reducer to your component.

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

It accepts three arguments: initialState, reducer, and init.

  • initialState: the initial value of our state, generally an object. In our example we could set the initial state so the shippingAddress is preset, for instance, if our user has filled our checkout form before and had their data saved.

      const initialState = {
        cart: [],
        shippingAddress: 'abc123',
        paymentMethod: '',
        orderReview: false,
      };
    
      const [state, dispatch] = useReducer(
          cartReducer,
          initialState
        );
    

  • reducer: a function that takes in 2 arguments: the current state and an action, and returns a new state result.
    An action is simply a Javascript object that has a type property. The type value (typically an uppercase snake case string e.g. ‘ADD_ITEM’) determines what block of code is run in our reducer function, by checking if the current action type matches a case, consequently determining how the state gets updated. In our cart example, we can see that our reducer function, cartReducer, can take in several action types and depending on the action type, runs a block of code from our switch statement that updates our state.

      // runs when action.type === "ADD_ITEM"
      function cartReducer(state, action) {
        switch (action.type) {
          // runs when action.type === "ADD_ITEM"
          case "ADD_ITEM":
            // returns new state with updated cart ( + new item)
            return { ...state, cart: [...state.cart, action.item] };
         // ...
          default:
            return state;
        }
      }
    

    We can also attach a payload to our action to use in our state update like in our "EDIT_ITEM_QTY" action type.
    It is important to note that state is read-only and immutable. Instead of mutating the state object directly, we return a new object instead.

      // wrong ❌
      state = state.push(item)
      return state
    
      // right ✅
      return { ...state, item }
    
  • init: The question mark denotes that it is an optional argument. It is an initializer function that returns the initial state by calling (initialState: any) => (). In its absence, initialState is used as the first version of our state.

      const initialState = {
        cart: [],
        shippingAddress: '',
        paymentMethod: '',
        orderReview: false,
      };
    
      const [state, dispatch] = useReducer(
          cartReducer,
          initialState,
          (state) => ({
            ...state,
            // get shipping address from localStorage
            shippingAddress: localStorage.getItem("shippingAddress"),
          })
        );
    

    This initializer function runs using the passed initialState value as a parameter, returning a new initial state; one with the shipping address loaded from localStorage

Dispatch📫

Now that we understand how reducer functions work and how they help us manage state updates, the next question is: how do we trigger the reducer function to execute? The answer lies in the dispatch function. It's the mechanism that tells the reducer when to run and what block of code to run. Quite fitting, isn't it?

When we utilize the useReducer hook, it returns two values: state and dispatch.

const [state, dispatch] = useReducer(reducer, initialState, init?)
  • The state is the current state of the object which you can read from.

  • The dispatch function which serves as a sort of courier, delivers actions to the reducer function. It tells the reducer function what action needs to the run, and in some cases, includes a payload with the necessary data to update the state, which as we know, triggers a re-render.
    In our Cart example, we can add a new item by clicking on the ‘Add Item’ button, which runs the addItemToCart function that dispatches the “ADD_ITEM” action type to our reducer function, as well as the item to be added payload. Our reducer function goes through the cases in our switch statement searching for the ADD_ITEM string, runs the block of code for that case, and updates the cart with our new item.

function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM":
      // returns new state with updated cart ( + new item)
      return { ...state, cart: [...state.cart, action.item] };
   // ...
    default:
      return state;
  }
}

// trigger on click of 'Add item' button
const addItemToCart = (item) => {
    // { type: "ADD_ITEM", item } => action
    dispatch({ type: "ADD_ITEM", item });
};

Why useReducer is Better

Now that we’ve covered how useReducer works, let’s discuss why you might choose useReducer over useState in some cases. While useState is great for simpler, isolated state updates, there are certain scenarios where useReducer truly shines.

  1. Single Source of Truth / Centralized State Logic : With useReducer, the entire state logic is encapsulated within one reducer function (cartReducer). This makes it easier to manage complex state transitions and improves code readability. Since all your state transitions are managed inside the reducer, it’s much easier to test, debug, and extend. You don’t have to worry about various setState calls spread throughout your component. You just focus on dispatching actions.

  2. Clear Action Handling: The reducer is action-based. You don’t have to worry about manually updating state for each action. Instead, you dispatch a clear action (like ADD_ITEM or REMOVE_ITEM), and the reducer determines what should happen to the state.

  3. Scalable and Maintainable: As your cart grows in complexity (e.g., adding more actions like CLEAR_CART, EDIT_ITEM_QTY, or even applying discounts), you can easily add more cases to the reducer. You only need to dispatch new action types, and the logic is neatly organized in one place. Each action is explicit, and you can easily see which state changes are triggered by which events. This is also especially useful when writing tests, as you can isolate actions and their corresponding changes to the state in a predictable manner.

  4. Predictable State Updates: Unlike useState, where you have to carefully manage state updates (especially when working with arrays or objects), useReducer makes it easier to avoid bugs because the reducer guarantees that your state transitions are handled in a predictable and easy to follow way.

When to Use useState Instead

That said, useReducer is not always necessary. For simple state management or when your state doesn’t have complex relationships, useState can be faster and easier to implement. Use useState when:

  • Your state is simple (e.g., a singular primitive data type like a boolean or number).

  • There are no complex relationships between the different state variables.

  • You don’t need to manage complex actions that involve multiple updates to the state.

Conclusion

While both useState and useReducer have their places in React development, useReducer is a powerful tool when dealing with complex state logic. It is a cleaner, more predictable way to manage state, especially as the complexity of your application grows. By understanding when and why to use useReducer, you can make better decisions and write more maintainable React applications.

Happy Coding🙌

P.S: Christmas is two days away. Step away from the keyboard