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.
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:
- One user action (like clicking "Sync Data") triggers multiple state changes (e.g.,
setLoading(true),setError(null),setData(null)). - The next state depends heavily on the previous state.
- 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
currentStateand anaction, then returns thenextState. - 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.
JAVASCRIPTimport { 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).
- Create an
initialStateobject with these three fields. - Implement a
filterReducerthat handles updates for each field. - Replace your individual
useStatehooks withuseReducer. - Ensure that dispatching an action for
SET_SEARCH_TERMdoesn't accidentally wipe out yourdateRangestate.
Common Pitfalls
- Mutating State: Never mutate the
stateobject 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
useReducerfor simple counters or toggles. It adds boilerplate. If you only have one piece of state,useStateis 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.
Work with me

Headless WordPress + Next.js Frontend Development
Keep WordPress for content, get a lightning-fast Next.js frontend. The best of both worlds — familiar editing, modern speed.

React & Next.js Dashboard / Admin UI Development
A clean, data-rich dashboard UI in React or Next.js — charts, tables, and real-time data that your users will actually enjoy using.