useReducer vs useState

Difficulty: Intermediate

Question

When should you use useReducer instead of useState? Show a practical example of each approach.

Answer

The decision between useState and useReducer is one of those "it depends" situations that actually has some pretty clear guidelines once you know what to look for. Let's break it down with real-world thinking.

useReducer is, at its heart, a state management pattern borrowed from Redux (which itself borrowed it from the Elm architecture). Instead of directly setting state to a new value, you dispatch an action - a plain object describing what happened - and a reducer function decides how to compute the new state based on that action. The reducer takes the current state and the action, and returns the new state. It's a pure function: same inputs, same outputs, no side effects.

So when you call const [state, dispatch] = useReducer(reducer, initialState), you get back the current state and a dispatch function. To update state, you call dispatch({ type: 'ADD_ITEM', payload: product }) instead of calling a setter directly.

This might seem like extra ceremony compared to useState, and for simple cases, it absolutely is. If you're toggling a boolean or tracking a single input value, useReducer is overkill. useState is cleaner and more straightforward.

But useReducer starts to shine - and it shines bright - in specific scenarios.

Scenario one: multiple related state values that need to update together. Imagine a shopping cart. You have items, total price, item count, discount percentage, and maybe a loyalty points calculation. With useState, you'd have five separate state variables and every action (add item, remove item, apply coupon, clear cart) would need to call multiple setters, keeping them all in sync. Miss one? You've got a bug where the item count says 3 but there are only 2 items. With useReducer, each action is handled in one place - the reducer function - and you return a new state object that has all values consistently updated together. There's no possibility of updating the total but forgetting to update the item count because they're computed in the same block of code.

Scenario two: complex state transitions. If your state updates involve logic like "if the current status is 'pending' and the action is 'approve', set status to 'approved' and timestamp to now, but if it's already 'approved', do nothing" - that kind of state machine logic reads much more clearly in a switch statement inside a reducer than scattered across multiple event handlers calling multiple setters.

Scenario three: when you need dispatch stability. Here's a subtle but important performance benefit. The dispatch function returned by useReducer has a stable identity - it never changes across renders. This is different from setState callbacks, which are also stable, but different from callback functions you create with useState patterns. When you're passing update functions through Context or to deeply nested memoized children, dispatch's referential stability means those children won't re-render just because the parent re-rendered.

Scenario four: testability. Because a reducer is a pure function that lives outside your component, you can test it in isolation without rendering anything. You just call reducer(previousState, action) and assert on the returned state. No component mounting, no DOM, no React rendering pipeline. This is a massive advantage for complex business logic.

Now, let's talk about the useReducer + Context pattern, because this is a lightweight alternative to Redux that's built right into React.

The idea is simple: create two contexts - one for state, one for dispatch. Use useReducer to manage the state. Provide the state through one context and dispatch through the other. Any component in the tree can read state from the state context and send actions through the dispatch context.

Why two separate contexts? Performance. If you put both state and dispatch in a single context, every component that uses dispatch (like a button that just needs to send an action) will re-render whenever state changes, even though dispatch itself never changes. By splitting them, components that only dispatch actions don't re-render when state updates.

Wrap this pattern in custom hooks (useCart() for state, useCartDispatch() for dispatch) and you have a clean, performant state management solution with zero external dependencies. For small to medium apps, this can replace Redux entirely.

So what's my practical decision framework?

Use useState when: you have one or two independent state values, the update logic is straightforward (just set it to a new value), and you don't need to share the updater across many components.

Use useReducer when: you have three or more related state values that update together, your state transitions have meaningful names and logic (add item, remove item, apply discount - not just "set X to Y"), you want to centralize and test state logic independently, or you need to pass a stable dispatch function through Context to avoid re-renders.

You can also start with useState and migrate to useReducer later when the complexity grows. They're interchangeable in terms of capability - anything you can do with one, you can do with the other. The difference is organizational clarity. useReducer trades a bit more boilerplate for a lot more structure, and that trade-off becomes worthwhile as complexity increases.

One thing to be clear about: reducers must be pure. No API calls, no localStorage writes, no random values, no Date.now() inside a reducer. If you need side effects triggered by state changes, handle them in a useEffect that watches the relevant state, or dispatch the action from an event handler that also handles the side effect. The reducer's job is strictly to compute the next state from the current state and the action. Nothing else.

Code examples

useState Problem: Related State Values

// With useState - scattered logic, easy to forget updates
function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [itemCount, setItemCount] = useState(0);
  const [discount, setDiscount] = useState(0);

  // Must remember to update ALL related values
  function addItem(product) {
    setItems(prev => [...prev, product]);
    setTotal(prev => prev + product.price);
    setItemCount(prev => prev + 1);
    // Forgot to update discount? Bug!
    if (itemCount + 1 >= 5) {
      setDiscount(0.1);
    }
  }

  function removeItem(id) {
    const item = items.find(i => i.id === id);
    setItems(prev => prev.filter(i => i.id !== id));
    setTotal(prev => prev - item.price);
    setItemCount(prev => prev - 1);
    // Same scattered update logic...
  }

  return <div>{/* UI */}</div>;
}

Multiple related useState calls lead to scattered update logic. It is easy to forget to update one piece of state, causing inconsistencies.

useReducer Solution: Centralized Logic

const initialState = {
  items: [],
  total: 0,
  itemCount: 0,
  discount: 0,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const newItems = [...state.items, action.payload];
      const newCount = state.itemCount + 1;
      return {
        items: newItems,
        total: state.total + action.payload.price,
        itemCount: newCount,
        discount: newCount >= 5 ? 0.1 : state.discount,
      };
    }
    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload);
      const newCount = state.itemCount - 1;
      return {
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - item.price,
        itemCount: newCount,
        discount: newCount < 5 ? 0 : state.discount,
      };
    }
    case 'CLEAR_CART':
      return initialState;
    default:
      return state;
  }
}

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

  return (
    <div>
      <p>Items: {state.itemCount} | Total: ${state.total}</p>
      <button onClick={() => dispatch({
        type: 'ADD_ITEM',
        payload: { id: 1, name: 'Shirt', price: 29.99 }
      })}>Add Shirt</button>
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
        Clear Cart
      </button>
    </div>
  );
}

All state transitions are in one place. Each action updates all related values atomically. The reducer is pure and easily testable.

useReducer with Context for State Sharing

const CartContext = createContext(null);
const CartDispatchContext = createContext(null);

// Provider wraps the app
function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  return (
    <CartContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartContext.Provider>
  );
}

// Custom hooks for clean access
function useCart() {
  return useContext(CartContext);
}
function useCartDispatch() {
  return useContext(CartDispatchContext);
}

// Any child can read state or dispatch actions
function AddToCartButton({ product }) {
  const dispatch = useCartDispatch();
  return (
    <button onClick={() => dispatch({
      type: 'ADD_ITEM', payload: product
    })}>Add to Cart</button>
  );
}

function CartSummary() {
  const { total, itemCount } = useCart();
  return <p>{itemCount} items - ${total.toFixed(2)}</p>;
}

Splitting state and dispatch into separate contexts prevents re-renders. Components that only dispatch actions (like buttons) don't re-render when state changes.

Key points

Concepts covered

useReducer, Dispatch, Action Patterns, Complex State