useState works fine for simple, independent values, but it becomes unwieldy once a component's state has many interdependent fields or complex update logic. This lesson covers useReducer ā React's built-in take on the action/reducer pattern popularized by Redux ā including pure reducer functions, dispatching actions, and why centralizing update logic pays off.
1What is useReducer?
While useState works well for simple, independent values, it becomes unwieldy once a component has several interdependent state fields ā you end up juggling many separate useState calls and complicated handlers that update several of them at once. useReducer is React's built-in alternative for managing that kind of complex state logic in one place.
// useReducer: The State Engine for Complex LogicState Management
Scaling up from simple variables
2The Redux Inspiration
If you've used Redux before, useReducer will feel familiar ā it implements the same action/reducer pattern directly inside React. A component dispatches an action describing *what happened*, and a separate reducer function decides *how the state should change* in response, keeping those two concerns cleanly separated.
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
default: return state;
}
}The Data Flow
Strict, Unidirectional Updates
3The Reducer Function
The reducer itself is just a plain JavaScript function with the signature (state, action) => newState. It takes the current state and an action object as its two arguments, inspects the action to decide what changed, and returns a brand-new state value ā it never returns anything except a full replacement for the state.
import { useReducer } from 'react';
const [state, dispatch] = useReducer(reducer, { count: 0 });4The Action Object
An action is a plain object describing what happened in the app. By convention it always has a type field, a string like 'ADD_TODO' identifying the event, and often a payload field carrying any data the reducer needs to perform the update, such as { type: 'ADD_TODO', payload: 'Buy milk' }.
dispatch({ type: 'add_todo', payload: 'Buy milk' });The Message
{ type, payload }
5The Dispatch Function
You never call the reducer directly ā instead, useReducer gives you a dispatch function. Calling dispatch(action) hands that action object off to React, which runs it through your reducer, receives the new state back, and re-renders the component with it, all without you managing the reducer call yourself.
// Centralized Logic:
// if 'delete' -> update state
// if 'edit' -> update stateDispatching
Sending mail to the Reducer.
6The Switch Statement
Inside the reducer, a switch statement on action.type is the standard pattern for routing each action to its own update logic ā one case per action type. It's important to always include a default case that simply returns the existing state unchanged, so an unrecognized action type doesn't accidentally wipe out the current state.
return { ...state, count: state.count + 1 }; // Pure updateThe Switchboard
Routing actions to logic
7Pure Functions
Reducers must be pure functions: no side effects like API calls or logging, and critically, they must never mutate the state argument directly. Writing state.count = state.count + 1 and returning state won't work ā React compares state by reference, so it won't detect that anything changed, and the UI won't update.
/* Reducer Lab: Complex Counter & Form Rendered */Immutability Rule
Do not touch the existing state.
8Immutable State Updates
Instead of mutating state, a reducer returns a brand-new object, typically built with the spread operator: return { ...state, count: state.count + 1 }. This copies every existing field from the old state into a new object and then overwrites just the fields that actually changed, giving React a new reference it can detect and react to.
const initial = { name: '', email: '', step: 1, errors: {} };The Spread Operator
{ ...old, new_stuff }
9Why useReducer?
The payoff becomes clear with something like a multi-field checkout form: with useState alone, you'd juggle separate state variables for each field plus loading and error flags, all updated individually across several handlers. With useReducer, all of that update logic lives inside the reducer, and the component just dispatches high-level intent like dispatch({ type: 'SUBMIT' }).
import { userReducer } from './reducers/user';
const [s, d] = useReducer(userReducer, init);Declarative Updates
Focus on WHAT, not HOW.
10Centralizing Logic
The clearest signal it's time to reach for useReducer is having five or six related useState calls that all update together, or a growing pile of if/else branches scattered across multiple event handlers just to keep them in sync.
Moving that logic into a single reducer function centralizes every possible state transition in one place, so there's exactly one function to read (and test) to understand every way the component's state can change.
// From messy useState... to structured useReducerSingle Source of Truth
Write logic once, use anywhere
11Decoupling Logic from UI
Dispatching an action is like sending a letter ā the component calling dispatch({ type: 'COMPLETE_ALL' }) doesn't need to know or care how that action is actually handled, only that it describes what happened. The reducer function, entirely separate from any component, decides what the resulting state should be.
Because the reducer is a plain, standalone function with no dependency on React rendering, it can be unit tested directly ā call it with a state and an action, and assert on the returned state ā without mounting any component at all.
dispatch({ type: 'COMPLETE_ALL' });Testability
Logic decoupled from UI rendering.
12Mastery Achieved
With useReducer, you now have a second, more structured tool for managing state alongside useState ā one built for cases where several pieces of state change together in predictable, well-defined ways described by discrete actions.
The next step is extending this pattern beyond a single component: useContext lets that same kind of centralized reducer-driven state be shared across a whole subtree of the component tree, without manually passing it down through every layer of props.
/* Next: Global Context (useContext) */State Engines Mastered ā
13Lazy Initialization
useReducer accepts an optional third argument, an init function: useReducer(reducer, initialArg, init). React calls init(initialArg) exactly once, on the first render, to compute the real starting state ā useful when that computation is expensive or depends on props.
function init(startCount) {
return { count: startCount, history: [] };
}
useReducer(reducer, props.startCount, init);Lazy Init š¦„
Expensive setup, computed once.
14Dispatch Has a Stable Identity
The dispatch function useReducer returns never changes across re-renders ā React guarantees the exact same reference every time. This means dispatch can safely be omitted from a useEffect dependency array, and passed down to deeply nested or memoized children without breaking their memoization, unlike a freshly-defined handler function.
const [state, dispatch] = useReducer(reducer, init);
// dispatch is the SAME function reference on every renderStable Forever
Never recreated by React.
15Step-by-Step Breakdown
What is useReducer?. Welcome to Complex State with useReducer. While useState is perfect for simple values like isOpen or count, it becomes unwieldy when you have state objects with many interdependent fields. useReducer is the professional alternative for managing complex state logic in React.
The Redux Inspiration. If you've ever heard of Redux, useReducer will look very familiar. It uses the exact same 'Action / Reducer' paradigm, but built directly into React. It separates the *what happened* (the Action) from the *how the state changes* (the Reducer).
The Reducer Function. The core of this system is the 'Reducer'. A reducer is simply a standard JavaScript function that takes two arguments: the currentState, and an action. Its only job is to look at the action, figure out what to do, and return the newState.
The Action Object. An action is just a plain JavaScript object that describes what just happened in the application. By convention, it always has a type property (a string like 'ADD_ITEM'), and optionally a payload property containing any data needed for the update.
By standard React and Redux convention, what property MUST an action object contain to tell the reducer what to do?
- ādata
- ātype
The Dispatch Function. To actually trigger a state change, you don't call the reducer directly. Instead, useReducer gives you a dispatch function. When a user clicks a button, you dispatch an action object. React automatically takes that action, hands it to your reducer, gets the new state, and re-renders the UI.
With useState, you update state using the setter function (like setCount(5)). With useReducer, how do you trigger a state update?
- āYou call dispatch() and pass it an action object
- āYou call the reducer() directly
The Switch Statement. Inside the reducer function, you almost always see a switch statement analyzing action.type. This acts like a routing station. If the action is 'INCREMENT', it routes to the increment logic. If it's 'DECREMENT', it goes there. And there should ALWAYS be a default case that returns the unchanged state if the action type is unknown.
Pure Functions. Rule #1 of Reducers: They must be PURE functions. A pure function has no 'side effects' (like fetching data from an API or modifying variables outside itself), and it MUST NEVER mutate the state argument directly. If you modify state.count = 5, React will not notice the change, and your UI will not update.
Immutable State Updates. Instead of modifying the state, you must return a BRAND NEW object. You usually do this using the JavaScript spread operator (...state). This copies all the existing fields from the old state into the new object, and then you overwrite only the specific fields you want to change.
Which of the following is the CORRECT way to update a state object inside a reducer when action.type is 'LOGOUT'?
- āstate.isLoggedIn = false; return state;
- āreturn { ...state, isLoggedIn: false };
Why useReducer?. Why go through all this trouble? Imagine a checkout form with 10 different fields, loading spinners, and error messages. With useState, you'd have 10 separate state variables and complicated onClick handlers updating them all at once. With useReducer, all the complex update logic is hidden inside the reducer. The component just says dispatch({ type: 'SUBMIT' }).
Centralizing Logic. Another massive benefit is centralized logic. If multiple buttons across your component all need to reset the form, you don't rewrite the reset logic in every onClick handler. You write the logic ONCE in the case 'RESET': block of the reducer, and every button just dispatches the 'RESET' action.
Decoupling Logic from UI. Because the reducer is just a standard JavaScript function that takes a state and returns a state, it doesn't need to live inside your React component! You can put your reducer in a separate state.js file. This makes testing incredibly easyāyou just call the function in a test file to verify the logic, without needing to mount React components.
Lazy Initialization. useReducer accepts an optional THIRD argument: an init function. Instead of useReducer(reducer, initialState), you write useReducer(reducer, initialArg, init). React calls init(initialArg) exactly once, on the first render, to compute the real starting state ā useful when that computation is expensive or depends on props.
Dispatch Has a Stable Identity. The dispatch function useReducer returns never changes across re-renders ā React guarantees the exact same reference every time. This means dispatch can safely be omitted from a useEffect dependency array, and passed down to deeply nested or memoized children without breaking their memoization, unlike a freshly-defined handler function.
Mastery Achieved. Reducer mastery achieved! You now know how to architect complex state engines. You understand the Action/Reducer/Dispatch paradigm, the critical importance of immutable updates, and how useReducer centralizes and decouples your business logic from your UI.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Dispatch-Driven UI Changes Still Need Explicit Announcements
A dispatched action that changes visible state (like completing a multi-step form) doesn't automatically inform screen reader users ā pair meaningful state transitions with an `aria-live` region update so the change is actually announced.
2Centralizing Logic in a Reducer Doesn't Centralize Accessibility Work
Moving state transitions into a reducer function is purely a data-layer change ā labels, focus management, and ARIA attributes still have to be handled in the component's template exactly as they would with plain useState.
SEO Implications
- 1
Reducer-Driven State Executes Entirely Client-Side
Like useState, a useReducer-managed value starts at its initial state during server-side rendering ā any content that only becomes visible after a dispatched action isn't present in the pre-hydration HTML a crawler evaluates.
- 2
Well-Tested Reducers Reduce the Risk of Logic Bugs Corrupting Displayed Content
Because a reducer is a pure, standalone function, it's straightforward to unit test exhaustively ā this indirectly protects any indexable content whose display depends on correct state transitions from a subtly broken action handler.
Best Practices
Keep the Reducer Function Pure ā No Side Effects Inside It
A reducer should only compute and return a new state object based on its current state and the dispatched action ā API calls, timers, and other side effects belong in `useEffect` or an event handler, never inside the reducer itself.
Model Actions as Past-Tense Events, Not Setter Calls
Prefer `dispatch({ type: 'itemAdded', payload: item })` over `dispatch({ type: 'setItems', payload: newItems })` ā describing what happened (rather than prescribing exactly how state should be set) keeps the reducer, not the dispatching component, in charge of the actual state transition logic.
Frequent Bugs
State managed by a reducer doesn't update even though the reducer function clearly returns a new value for that case.
The reducer likely mutated and returned the same object reference (e.g., `state.count++; return state;`) rather than constructing a genuinely new object ā React compares state by reference, so returning the same object, even with mutated properties, doesn't trigger a re-render.
Dispatching an action with an unrecognized `type` string silently does nothing, with no error.
This is usually intentional reducer design ā the `default` case in the switch statement typically just returns the existing state unchanged for unknown action types, so a typo'd action type fails silently rather than crashing. Double-check the exact `type` string matches what the reducer's switch statement expects.
Real-World Examples
Reducer-Managed Multi-Step Form State
A checkout form centralizes all its field values, current step, and validation errors into one reducer, letting the component dispatch simple, declarative actions like `dispatch({ type: 'nextStep' })` instead of juggling several related useState calls.
function checkoutReducer(state, action) {
switch (action.type) {
case 'nextStep': return { ...state, step: state.step + 1 };
case 'setField': return { ...state, fields: { ...state.fields, [action.field]: action.value } };
default: return state;
}
}