Skip to content

useReducer Hook — Complex State Logic in React

DodaTech 4 min read

In this tutorial, you'll learn about usereducer hook. We cover key concepts, practical examples, and best practices.

What You'll Learn

Use the useReducer hook for complex state logic — reducers, actions, dispatch, comparing with useState, and building practical examples like a form manager and shopping cart.

Why It Matters

When state logic becomes complex (multiple sub-values, interdependent updates, or next-state depends on previous state), useReducer keeps it predictable and testable.

Real-World Use

A multi-step form with validation, a shopping cart with add/remove/update/clear, a game scoreboard, or any state with multiple transition types.

useReducer vs useState

// useState: simple, one value at a time
const [count, setCount] = useState(0);

// useReducer: complex, multiple transitions
const [state, dispatch] = useReducer(reducer, initialState);
Aspect useState useReducer
Complexity Simple Complex
State type Single value Object or array
Updates Direct value or function Dispatched actions
Testability Harder Easy (pure reducer)
Boilerplate Minimal More
When to use Independent values Related values, complex logic

Basic Reducer

import { useReducer } from "react";

// Reducer: a pure function (state, action) → newState
function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    case "RESET":
      return { count: 0 };
    case "SET":
      return { count: action.payload };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>-</button>
      <button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
      <button onClick={() => dispatch({ type: "SET", payload: 100 })}>
        Set to 100
      </button>
    </div>
  );
}

Reducer with Multiple State Fields

const initialState = {
  isLoading: false,
  data: null,
  error: null,
  lastUpdated: null,
};

function fetchReducer(state, action) {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, isLoading: true, error: null };
    case "FETCH_SUCCESS":
      return {
        ...state,
        isLoading: false,
        data: action.payload,
        lastUpdated: Date.now(),
      };
    case "FETCH_ERROR":
      return {
        ...state,
        isLoading: false,
        error: action.payload,
      };
    case "CLEAR":
      return initialState;
    default:
      return state;
  }
}

function UserData({ userId }) {
  const [state, dispatch] = useReducer(fetchReducer, initialState);
  const { isLoading, data, error } = state;

  async function loadUser() {
    dispatch({ type: "FETCH_START" });
    try {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      dispatch({ type: "FETCH_SUCCESS", payload: data });
    } catch (err) {
      dispatch({ type: "FETCH_ERROR", payload: err.message });
    }
  }

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <div>{/* render data */}</div>;
}

Shopping Cart with useReducer

const cartReducer = (state, action) => {
  switch (action.type) {
    case "ADD_ITEM": {
      const existing = state.items.find(i => i.id === action.item.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.item.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          ),
        };
      }
      return {
        ...state,
        items: [...state.items, { ...action.item, quantity: 1 }],
      };
    }
    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.id),
      };
    case "UPDATE_QUANTITY":
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.id
            ? { ...i, quantity: Math.max(0, action.quantity) }
            : i
        ),
      };
    case "CLEAR":
      return { ...state, items: [] };
    case "APPLY_DISCOUNT":
      return { ...state, discount: action.percentage };
    default:
      return state;
  }
};

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, {
    items: [],
    discount: 0,
  });

  const total = cart.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  const discountedTotal = total * (1 - cart.discount / 100);

  return (
    <div>
      <h2>Shopping Cart ({cart.items.length} items)</h2>
      {cart.items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={e =>
              dispatch({
                type: "UPDATE_QUANTITY",
                id: item.id,
                quantity: parseInt(e.target.value),
              })
            }
          />
          <span>${(item.price * item.quantity).toFixed(2)}</span>
          <button
            onClick={() =>
              dispatch({ type: "REMOVE_ITEM", id: item.id })
            }
          >
            ×
          </button>
        </div>
      ))}
      <p>Total: ${discountedTotal.toFixed(2)}</p>
      <button onClick={() => dispatch({ type: "CLEAR" })}>
        Clear Cart
      </button>
      <button
        onClick={() =>
          dispatch({ type: "APPLY_DISCOUNT", percentage: 10 })
        }
      >
        Apply 10% Discount
      </button>
    </div>
  );
}

Testing Reducers

Reducers are pure functions — easy to test:

// cartReducer.test.js
test("adds item to cart", () => {
  const initialState = { items: [], discount: 0 };
  const action = {
    type: "ADD_ITEM",
    item: { id: 1, name: "Test", price: 10 },
  };
  const result = cartReducer(initialState, action);
  expect(result.items).toHaveLength(1);
  expect(result.items[0].quantity).toBe(1);
});

test("increments quantity for existing item", () => {
  const initialState = {
    items: [{ id: 1, name: "Test", price: 10, quantity: 1 }],
    discount: 0,
  };
  const action = {
    type: "ADD_ITEM",
    item: { id: 1, name: "Test", price: 10 },
  };
  const result = cartReducer(initialState, action);
  expect(result.items[0].quantity).toBe(2);
});

When to useReducer

✅ Multiple state fields that change together
✅ Complex state transitions
✅ Next state depends on previous state
✅ State logic is hard to understand with useState
✅ You want to test state logic in isolation
✅ You're building a form with many fields

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro