DEV Community

Cover image for What Is useReducer in React? A Beginner's Guide That Finally Makes It Click
Ebenezer
Ebenezer

Posted on

What Is useReducer in React? A Beginner's Guide That Finally Makes It Click

Hey Folks!

Today, I just want to share what I learned in class. I hope this will be useful for beginner and intermediate-level budding developers as well.

Right now, I’m not working on large-scale projects yet. The hook I learned today is mostly used in complex applications.

When a project becomes more complex, we may end up using many useState hooks. At that point, the code can become messy and difficult to manage. That’s where this hook becomes very useful.

Here's exactly what we'll cover:

  • What useReducer actually is — in plain English
  • The structure — broken down piece by piece
  • Why it exists and when useState just isn't enough
  • A real use case — a shopping cart — so you feel it, not just read it

1. What Is useReducer?

useReducer is a React Hook — just like useState — but it's built for managing state that has multiple moving parts.

Think of it like this.

useState is a light switch. One thing. On or off.

useReducer is a remote control. Many buttons. Each button does something different. But there's only one remote.

The idea is simple: instead of writing five different setState calls scattered across your component, you write one function — the reducer — that handles all the state changes in one place.

One function. One truth. All changes flow through it.


2. The Structure — What Are All These Pieces?

This is where most tutorials lose you. Let's slow down and meet each piece.

const [state, dispatch] = useReducer(reducer, initialState);
Enter fullscreen mode Exit fullscreen mode

What just happened here is — we're setting up our "state remote control."

  • state — the current state value (like state in useState)
  • dispatch — the function you call to trigger a change (replaces setState)
  • reduceryour function that decides how state changes
  • initialState — what state looks like at the very beginning

Now let's look at the reducer function itself.

// This function decides how state should change based on what happened
function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    case "RESET":
      return { count: 0 };
    default:
      return state; // if nothing matches, don't change anything
  }
}
Enter fullscreen mode Exit fullscreen mode

What just happened here is — the reducer is like a security guard at the gate. You tell him what happened (the action), and he decides what to do (the new state).

The action is just an object with a type field — like a label. "INCREMENT" means "I want to go up." "DECREMENT" means "go down." You name them. You decide.

And the dispatch function? That's how you talk to the guard.

// Calling dispatch is like pressing a button on the remote
dispatch({ type: "INCREMENT" });
dispatch({ type: "RESET" });
Enter fullscreen mode Exit fullscreen mode

You don't set the state. You describe what happened — and the reducer figures out the rest.


3. Why Does useReducer Even Exist?

useState is great. Honestly, for simple things — a modal open/close, a form input value — use it. No shame.

But imagine you're building a form with 6 fields.

// This starts to feel like juggling
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [step, setStep] = useState(1);
const [submitted, setSubmitted] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Now imagine that pressing "Submit" needs to: set loading to true, clear the error, and move to step 2 — all at once.

You'd write:

setLoading(true);
setError(null);
setStep(2);
Enter fullscreen mode Exit fullscreen mode

Three separate calls. Three re-renders (potentially). And if any of them depend on each other? Things get messy fast.

useReducer fixes this by bundling all of that state into one object — and all of those changes into one function.

// One dispatch. All three things happen together.
dispatch({ type: "SUBMIT_FORM" });
Enter fullscreen mode Exit fullscreen mode

Clean. Predictable. And when something breaks, you know exactly where to look — the reducer.


4. A Real Use Case — Shopping Cart

Let's build something real. A simple cart that can add items, remove items, and clear everything.

This is exactly the kind of feature where useReducer shines.

import { useReducer } from "react";

// Starting point — the cart is empty
const initialState = {
  items: [],
};

// The reducer — all cart logic lives here
function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM":
      // Add the new product to the items array
      return { items: [...state.items, action.payload] };

    case "REMOVE_ITEM":
      // Keep everything except the item with the matching id
      return {
        items: state.items.filter((item) => item.id !== action.payload),
      };

    case "CLEAR_CART":
      // Wipe everything — start fresh
      return { items: [] };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

What just happened here is — we wrote all the cart logic in one place. Add. Remove. Clear. The reducer is the single source of truth for everything cart-related.

Now the component:

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

  // These are the products available to add
  const products = [
    { id: 1, name: "Laptop", price: 999 },
    { id: 2, name: "Headphones", price: 199 },
    { id: 3, name: "Mouse", price: 49 },
  ];

  return (
    <div>
      <h2> Cart ({state.items.length} items)</h2>

      {/* Show what's in the cart */}
      {state.items.map((item, index) => (
        <div key={index}>
          <span>{item.name} — ₹{item.price}</span>
          {/* Tell the reducer: "remove this item" */}
          <button onClick={() => dispatch({ type: "REMOVE_ITEM", payload: item.id })}>
            Remove
          </button>
        </div>
      ))}

      <hr />

      {/* Show available products */}
      {products.map((product) => (
        <div key={product.id}>
          <span>{product.name}</span>
          {/* Tell the reducer: "add this product to the cart" */}
          <button onClick={() => dispatch({ type: "ADD_ITEM", payload: product })}>
            Add to Cart
          </button>
        </div>
      ))}

      {/* Wipe the entire cart */}
      <button onClick={() => dispatch({ type: "CLEAR_CART" })}>
        Clear Cart
      </button>
    </div>
  );
}

export default ShoppingCart;
Enter fullscreen mode Exit fullscreen mode

What just happened here is — the component doesn't think about how state changes. It just says what happened: "ADD_ITEM", "REMOVE_ITEM", "CLEAR_CART". The reducer handles the rest.

That's the beauty of it. Your UI describes events. Your reducer handles logic. They never mix.


5. Common Mistakes Beginners Make

Mistake 1 — Mutating state directly inside the reducer

// Wrong — never mutate state directly
case "ADD_ITEM":
  state.items.push(action.payload); // this breaks React's re-render
  return state;

//  Right — always return a NEW object
case "ADD_ITEM":
  return { items: [...state.items, action.payload] };
Enter fullscreen mode Exit fullscreen mode

React needs a new reference to know that something changed. If you mutate the old state, it sees "same object" and skips the re-render.

Mistake 2 — Forgetting the default case

Always include default: return state. Without it, an unrecognized action type silently breaks your state.

Mistake 3 — Using useReducer for simple toggles

If your state is just true or false, useState is the right tool. useReducer is for complex state — multiple fields, interdependent updates, or many action types.

Top comments (0)