Back to Blog
Lesson 8 of the Intermediate React: Hooks, State & Data Patterns course
ReactJune 25, 20263 min read

Complex State with useReducer: A React Developer's Guide

Master useReducer to manage complex state transitions in React. Learn how to write a reducer function, dispatch actions, and clean up your component logic.

ReactuseReducerstate managementhooksJavaScriptfrontend

Previously in this course, we explored how to build custom hooks in Introduction to Custom Hooks to encapsulate logic. While custom hooks are excellent for sharing behavior, they don't always solve the problem of managing "spaghetti" state—where multiple useState calls lead to interdependent updates that are hard to track.

When your dashboard's state logic grows beyond simple toggles, useReducer becomes your most powerful tool. It allows you to move state transition logic out of your components and into a predictable, testable function.

Why useReducer?

In smaller components, useState is perfectly fine. However, as our dashboard project grows, we often face scenarios where:

  1. One user action (like clicking "Sync Data") triggers multiple state changes (e.g., setLoading(true), setError(null), setData(null)).
  2. The next state depends heavily on the previous state.
  3. The business logic for updating state is becoming too complex for an event handler.

useReducer solves this by forcing you to define all possible state transitions in one place: a reducer function. This pattern is foundational for React state management: Reducers vs. State Machines and keeps your components focused on rendering rather than logic orchestration.

The Anatomy of useReducer

To use useReducer, you need three things:

  • Initial State: The starting shape of your data.
  • Reducer Function: A pure function that takes the currentState and an action, then returns the nextState.
  • Dispatch: A function provided by the hook to trigger state updates.

Worked Example: A Dashboard Data Fetcher

Let's refactor our dashboard's data fetching logic. Instead of managing isLoading, data, and error as three separate useState variables, we'll group them into one object.

JAVASCRIPT
import { useReducer } from CE9178">'react';

// 1. Define the initial state
const initialState = {
  data: null,
  loading: false,
  error: null,
};

// 2. The reducer function: Pure logic for transitions
function dashboardReducer(state, action) {
  switch (action.type) {
    case CE9178">'FETCH_START':
      return { ...state, loading: true, error: null };
    case CE9178">'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload };
    case CE9178">'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function Dashboard() {
  const [state, dispatch] = useReducer(dashboardReducer, initialState);

  const fetchData = async () => {
    dispatch({ type: CE9178">'FETCH_START' });
    try {
      const response = await fetch(CE9178">'/api/dashboard');
      const data = await response.json();
      dispatch({ type: CE9178">'FETCH_SUCCESS', payload: data });
    } catch (err) {
      dispatch({ type: CE9178">'FETCH_ERROR', payload: err.message });
    }
  };

  return (
    <div>
      {state.loading && <p>Loading...</p>}
      {state.error && <p>Error: {state.error}</p>}
      {state.data && <pre>{JSON.stringify(state.data, null, 2)}</pre>}
      <button onClick={fetchData}>Refresh Data</button>
    </div>
  );
}

Notice how dispatch acts as an event emitter. The component no longer cares how the state is updated; it only broadcasts what happened. This separation is the key to maintaining a clean Review of State Management: Choosing the Right React Strategy as your app scales.

Hands-on Exercise

In your dashboard project, locate the component handling the user's "filter" settings (e.g., date range, category selection, and search term).

  1. Create an initialState object with these three fields.
  2. Implement a filterReducer that handles updates for each field.
  3. Replace your individual useState hooks with useReducer.
  4. Ensure that dispatching an action for SET_SEARCH_TERM doesn't accidentally wipe out your dateRange state.

Common Pitfalls

  • Mutating State: Never mutate the state object directly in your reducer. Always return a new object (e.g., using the spread operator { ...state }).
  • Side Effects in Reducers: Reducers must be pure. Do not perform API calls or generate random numbers inside the reducer function. Keep side effects in your event handlers or useEffect.
  • Over-Engineering: Don't use useReducer for simple counters or toggles. It adds boilerplate. If you only have one piece of state, useState is almost always the correct choice.

Recap

By centralizing your logic, useReducer makes complex state transitions predictable. You define actions to describe intent and a reducer to handle the state change. This pattern prevents "impossible states"—like having both loading and error be true—because you control the transition logic explicitly.

Up next: We will dive deeper into managing object-based state, focusing on how to handle deeply nested data structures immutably.

Similar Posts