React 40 ⚛️ State Management with useReducer
useState works well when a component manages one or two independent values. A boolean for a modal, a string for an input, a number for a counter — each is a separate useState call, each updates independently, and the logic that changes each value is simple enough to live inline in an event handler. But when a component manages several values that change together, or when the next state depends on the previous state in ways that involve multiple fields, useState begins to strain. The update logic spreads across event handlers, the relationships between fields are implicit, and testing the state transitions requires rendering the component and simulating user interactions.
useReducer is the alternative. It consolidates state management into a single function — the reducer — that takes the current state and an action, and returns the next state. Every state transition goes through that function. The component dispatches actions instead of calling setters. The reducer decides what each action does to the state. The logic that was scattered across handlers becomes centralized, testable, and explicit.
The trade-off is ceremony. A useState counter is three lines. A useReducer counter requires a reducer function, an initial state, a dispatch call, and an action object. For simple cases, that ceremony is overhead without benefit. For complex cases — forms with many fields, multi-step wizards, undo/redo history, state machines — the ceremony pays for itself many times over. The question is not whether useReducer is better than useState, but whether the state’s complexity justifies the reducer pattern.
This chapter covers three areas. First, why useReducer exists — what problem it solves that useState cannot, and the conditions under which a reducer is the right tool. Second, how to write and use a reducer — the reducer function signature, the dispatch mechanism, the action shape, and the lazy initialization option. Third, how to structure reducers for real applications — combining useReducer with context, extracting reducers into separate files, handling immutability correctly, and recognizing when a reducer should be replaced by a store. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the reducer loop and the difference between useState and useReducer.
Key point: A reducer is a pure function that takes the current state and an action and returns the next state. useReducer gives a component a dispatch function to send actions and a state value that is always the result of the reducer. The pattern centralizes state transitions, makes them testable in isolation, and is the right choice when the next state depends on multiple fields or when the update logic is complex .
Why useReducer exists
The scattered-update problem. A component with five useState calls has five setters. Each setter is called from a different event handler. The logic that updates one field may depend on the value of another field, so the handler reads the other field’s state and computes the next value. This works, but the logic is spread across the component body. There is no single place to look to understand how the state changes. There is no single function to test. A reducer consolidates all of that logic into one function. Every transition is a case in a switch statement. Every action is a named event. The state changes are no longer scattered — they are enumerated .
The multi-field coherence problem. When several fields must change together, useState requires multiple setter calls. If the component updates firstName and lastName in one handler, it calls two setters, and React batches them into one re-render. But if the handler is interrupted, or if the two setters are called from different places, the fields can fall out of sync. A reducer makes multi-field updates atomic. One action produces one new state object with all the fields updated together. There is no intermediate state where one field has changed and the other has not .
The complex-transition problem. Some state transitions involve more than assigning a new value. A form submission might validate, transform, and reset fields. A list operation might filter, sort, and paginate. A wizard might advance the step, mark the current step complete, and enable the next step. With useState, each of these operations is a sequence of setter calls. With a reducer, each is a single action whose case in the reducer performs the entire transition. The transition is described in one place and can be tested by calling the reducer with a state and an action .
The testability problem. Testing useState logic requires rendering the component, finding the input, simulating a change, and asserting on the rendered output. The test is coupled to the component’s markup and behavior. A reducer is a pure function. You call it with a state and an action, and you assert on the returned state. No rendering, no DOM, no simulation. The reducer’s behavior is verified directly. This is not just convenience — it changes what is practical to test. A reducer with twenty actions can have twenty test cases, each one a direct function call .
The undo/redo and history problem. A reducer’s state is a value. If you keep a history of previous states, you can implement undo by dispatching an action that restores a previous state. You can implement redo by keeping a future stack. You can implement time-travel debugging by logging every action and replaying them. None of this is practical with scattered useState calls, because there is no single function that produces the next state from the current state. The reducer pattern makes the state transition a first-class value that can be recorded, replayed, and inverted .
The trade-off. useReducer requires more code than useState for simple cases. A boolean toggle becomes a reducer with two actions and a switch statement. The ceremony is real. The benefit appears when the state has multiple fields, when transitions depend on previous state, or when the logic needs to be tested or shared. The rule of thumb is: use useState until the state’s complexity makes you want to extract the update logic into a function. At that point, the function you want to extract is a reducer.
a. The reducer function
A reducer is a pure function with the signature (state, action) => nextState. It takes the current state and an action object, and returns the next state. It does not mutate the state. It does not call any side effects. It does not dispatch. It only computes and returns the next state .
function counterReducer(state, action) {
switch (action.type) {
case 'incremented':
return { count: state.count + 1 };
case 'decremented':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
The reducer’s state is an object. The action is an object with a type field and any additional data the reducer needs. The switch statement enumerates every action the reducer handles. The default case throws, which makes unknown actions visible during development rather than silently returning the current state .
The reducer must be pure. It must not mutate state or action. It must not call setState, dispatch, or perform I/O. The same state and action must always produce the same next state. This is what makes the reducer testable and what allows React to safely call it multiple times during development to detect impurities .
b. The useReducer hook
The useReducer hook takes the reducer and the initial state, and returns the current state and a dispatch function:
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
state is the current state, produced by the most recent call to the reducer. dispatch is a function that sends an action to the reducer. Calling dispatch schedules a re-render with the state returned by the reducer for that action .
The dispatch call takes an action object:
dispatch({ type: 'incremented' });
React calls the reducer with the current state and the action, receives the next state, and re-renders the component with that new state. The dispatch function’s identity is stable across renders — React guarantees that dispatch does not change, so it can be safely omitted from dependency arrays .
An action can carry additional data beyond the type:
dispatch({ type: 'typed', field: 'email', value: 'a@b.com' });
The reducer reads action.field and action.value to compute the next state. The action’s shape is a convention, not a requirement. Some reducers use a payload field to carry data. Some use the action object directly. What matters is that the reducer and the dispatcher agree on the shape .
c. Lazy initialization and the third argument
useReducer accepts an optional third argument: an init function. When provided, the initial state is computed by calling init(initialArg). This is useful when the initial state is expensive to compute or when it should be derived from props .
function init(initialCount) {
return { count: initialCount };
}
const [state, dispatch] = useReducer(counterReducer, 0, init);
The second argument — 0 in this example — is passed to init. The reducer receives the return value of init as its initial state. This pattern lets the initial state be computed once, at mount, rather than on every render. It also lets the initial state be derived from a prop without the reducer needing to know about props .
Without the init function, the second argument is the initial state directly. With the init function, the second argument is the argument to init, and the return value of init is the initial state. The distinction matters when the initial state is an object created from props — without init, the object would be recreated on every render, but with init, it is created once.
Complete Example Session
// ============================================
// PART 1: THE SAME STATE WITH useState
// ============================================
// A form with four fields and three states.
// The update logic is scattered across handlers.
function SignupFormUseState() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [errors, setErrors] = useState({});
function handleEmail(e) {
setEmail(e.target.value);
setErrors((prev) => ({ ...prev, email: null }));
}
function handlePassword(e) {
setPassword(e.target.value);
setErrors((prev) => ({ ...prev, password: null }));
}
function handleConfirm(e) {
setConfirm(e.target.value);
setErrors((prev) => ({ ...prev, confirm: null }));
}
function handleSubmit() {
const next = {};
if (!email.includes('@')) next.email = 'Invalid email';
if (password.length < 8) next.password = 'Too short';
if (password !== confirm) next.confirm = 'Does not match';
setErrors(next);
}
// Six pieces of state, four handlers, logic spread out.
}
// ============================================
// PART 2: THE SAME STATE WITH useReducer
// ============================================
// The state is one object. The transitions are
// enumerated as actions. The logic is in one function.
const initialFormState = {
email: '',
password: '',
confirm: '',
errors: {},
};
function formReducer(state, action) {
switch (action.type) {
case 'typed':
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field]: null },
};
case 'submitted':
return {
...state,
errors: validate(state),
};
case 'reset':
return initialFormState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// ============================================
// PART 3: THE VALIDATION FUNCTION
// ============================================
// Validation is a pure function of the state.
// It is separate from the reducer, which keeps
// the reducer focused on transitions.
function validate(state) {
const errors = {};
if (!state.email.includes('@')) errors.email = 'Invalid email';
if (state.password.length < 8) errors.password = 'Too short';
if (state.password !== state.confirm) errors.confirm = 'Does not match';
return errors;
}
// ============================================
// PART 4: THE COMPONENT WITH useReducer
// ============================================
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
function handleChange(field) {
return (e) => dispatch({ type: 'typed', field, value: e.target.value });
}
function handleSubmit(e) {
e.preventDefault();
dispatch({ type: 'submitted' });
}
return (
<form onSubmit={handleSubmit}>
<input value={state.email} onChange={handleChange('email')} />
{state.errors.email && <p>{state.errors.email}</p>}
<input value={state.password} onChange={handleChange('password')} />
{state.errors.password && <p>{state.errors.password}</p>}
<input value={state.confirm} onChange={handleChange('confirm')} />
{state.errors.confirm && <p>{state.errors.confirm}</p>}
<button type="submit">Sign up</button>
</form>
);
}
// ============================================
// PART 5: TESTING THE REDUCER IN ISOLATION
// ============================================
// The reducer is a pure function. It can be tested
// without rendering anything.
test('typing clears the field error', () => {
const state = { ...initialFormState, errors: { email: 'Invalid' } };
const next = formReducer(state, {
type: 'typed',
field: 'email',
value: 'a@b.com',
});
expect(next.errors.email).toBe(null);
expect(next.email).toBe('a@b.com');
});
test('submitting with invalid email sets the error', () => {
const state = { ...initialFormState, email: 'bad' };
const next = formReducer(state, { type: 'submitted' });
expect(next.errors.email).toBe('Invalid email');
});
// ============================================
// PART 6: DISPATCH IS STABLE
// ============================================
// React guarantees that dispatch does not change
// between renders. It can be omitted from dependency
// arrays and passed to child components without
// causing re-renders.
function EmailField({ value, dispatch }) {
return (
<input
value={value}
onChange={(e) =>
dispatch({ type: 'typed', field: 'email', value: e.target.value })
}
/>
);
}
// EmailField receives dispatch as a prop. Because
// dispatch is stable, EmailField does not re-render
// when the parent re-renders for unrelated reasons.
// ============================================
// PART 7: LAZY INITIALIZATION
// ============================================
// When the initial state is expensive or derived
// from props, pass an init function as the third
// argument. It runs once, at mount.
function init(initialEmail) {
return { ...initialFormState, email: initialEmail };
}
function SignupFormWithEmail({ initialEmail }) {
const [state, dispatch] = useReducer(formReducer, initialEmail, init);
// init runs once with initialEmail
}
// ============================================
// PART 8: COMBINING useReducer WITH CONTEXT
// ============================================
// The reducer manages the state. A context makes
// the state and dispatch available to descendants
// without prop drilling.
const FormStateContext = createContext(null);
const FormDispatchContext = createContext(null);
function FormProvider({ children }) {
const [state, dispatch] = useReducer(formReducer, initialFormState);
return (
<FormStateContext value={state}>
<FormDispatchContext value={dispatch}>
{children}
</FormDispatchContext>
</FormStateContext>
);
}
function useFormState() {
const context = useContext(FormStateContext);
if (context === null) throw new Error('useFormState requires FormProvider');
return context;
}
function useFormDispatch() {
const context = useContext(FormDispatchContext);
if (context === null) throw new Error('useFormDispatch requires FormProvider');
return context;
}
// ============================================
// PART 9: SPLITTING STATE AND DISPATCH CONTEXTS
// ============================================
// State and dispatch are separate contexts. Components
// that only dispatch do not re-render when the state
// changes, because they do not read the state context.
function SubmitButton() {
const dispatch = useFormDispatch();
return <button onClick={() => dispatch({ type: 'submitted' })}>Submit</button>;
}
// SubmitButton reads only the dispatch context, which
// never changes. It does not re-render when the form
// state changes.
// ============================================
// PART 10: WHEN TO USE useReducer OVER useState
// ============================================
// USE useReducer WHEN:
// - The state has multiple related fields
// - The next state depends on the previous state
// - The update logic is complex or testable
// - The state transitions benefit from being named
// - Undo/redo or history is needed
//
// USE useState WHEN:
// - The state is a single independent value
// - The updates are simple assignments
// - The logic is trivial enough to inline
// - The component has one or two state values
The ten parts show the full arc: the scattered useState version, the centralized useReducer version, the pure validation function, the component wiring, isolated reducer tests, the stable dispatch, lazy initialization, the context combination, split state and dispatch contexts, and the decision criteria.
Quick Reference
useReducer Signature
| Parameter | Meaning |
|---|---|
reducer | Pure function (state, action) => nextState |
initialState | Initial state value |
init (optional) | Function that computes initial state from initialArg |
| Returns | [state, dispatch] |
Reducer Rules
| Rule | Meaning |
|---|---|
| Pure | Same input always produces same output |
| No mutation | Returns new state, never modifies the old |
| No side effects | No dispatch, no I/O, no setState |
| Exhaustive | Handles every action type; throws on unknown |
Action Shape
| Field | Purpose |
|---|---|
type | String identifying the action |
| payload fields | Any data the reducer needs |
| Convention | payload or spread fields; pick one and stay consistent |
useState vs useReducer
| Situation | useState | useReducer |
|---|---|---|
| Single value | ✅ | Overkill |
| Multiple related fields | Awkward | ✅ |
| Next state depends on previous | Possible | ✅ |
| Complex transitions | Scattered | Centralized |
| Testable in isolation | No | Yes |
| Undo/redo | Impractical | Natural |
| Simple toggle | ✅ | Overkill |
Lazy Initialization
| Usage | Behavior |
|---|---|
useReducer(reducer, initialState) | initialState used directly |
useReducer(reducer, initialArg, init) | init(initialArg) computed once at mount |
Best Practices
✅ Do This:
// Keep the reducer pure
function reducer(state, action) { return nextState; } // ✅
// Return new objects, never mutate
return { ...state, count: state.count + 1 }; // ✅
// Throw on unknown actions
default: throw new Error(`Unknown: ${action.type}`); // ✅
// Use lazy initialization for expensive initial state
const [state, dispatch] = useReducer(reducer, props, init); // ✅
// Test the reducer as a pure function
expect(reducer(state, action)).toEqual(expected); // ✅
// Split state and dispatch contexts
<StateContext value={state}><DispatchContext value={dispatch}> // ✅
// Pass dispatch to children without memoization
<Child dispatch={dispatch} /> // ✅
❌ Don’t Do This:
// Don't mutate state
state.count = state.count + 1; return state; // ❌
// Don't dispatch inside the reducer
dispatch({ type: 'other' }); // ❌
// Don't perform side effects in the reducer
fetch('/api'); // ❌
// Don't use useReducer for a single boolean
const [open, dispatch] = useReducer(toggleReducer, false); // ❌
// Don't forget the default case
switch (action.type) { case 'a': return ...; } // ❌
// Don't recreate initial state on every render
useReducer(reducer, { count: computeExpensive() }); // ❌
// Don't put state and dispatch in one context
<FormContext value={{ state, dispatch }}> // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Reducer mutates state | Confusing reducers with setters | Return new objects with spread |
| Unknown action silently ignored | Missing default case | Throw on unknown actions |
| Initial state recomputed every render | Passing an object literal | Use lazy initialization with init |
| All consumers re-render on state change | State and dispatch in one context | Split into two contexts |
| Reducer performs side effects | Mixing transitions with I/O | Move side effects to event handlers or effects |
| Action shape inconsistent | No convention | Pick payload or spread and stay consistent |
| Reducer too large | Handling every action in one function | Split by domain or use multiple reducers |
| useReducer for trivial state | Over-applying the pattern | Use useState for simple values |
Real-World Examples
1. Counter Reducer
function counterReducer(state, action) {
switch (action.type) {
case 'incremented': return { count: state.count + 1 };
case 'decremented': return { count: state.count - 1 };
default: throw new Error(action.type);
}
}
2. Form Reducer
case 'typed':
return { ...state, [action.field]: action.value };
3. Todo Reducer
case 'added':
return [...state, { id: action.id, text: action.text, done: false }];
4. Toggle Reducer
case 'toggled':
return state.map((t) => t.id === action.id ? { ...t, done: !t.done } : t);
5. Lazy Initialization
useReducer(reducer, props.initialCount, (n) => ({ count: n }));
6. Dispatch as a Prop
<Child dispatch={dispatch} />
7. Split Contexts
<StateContext value={state}>
<DispatchContext value={dispatch}>
8. Reducer with Payload
dispatch({ type: 'added', payload: { id: 1, text: 'Buy milk' } });
9. Reducer Test
expect(reducer({ count: 0 }, { type: 'incremented' })).toEqual({ count: 1 });
10. Undo with History
case 'undo':
return { ...state, past: state.past.slice(0, -1), present: state.past.at(-1) };
Visual
The Reducer Loop
┌──────────────────────────────────────────────────────────────┐
│ THE REDUCER LOOP │
│ │
│ Component │
│ │ │
│ │ dispatch({ type: 'incremented' }) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ REDUCER │ │
│ │ (state, action) => nextState │ │
│ │ │ │
│ │ state = { count: 0 } │ │
│ │ action = { type: 'incremented' } │ │
│ │ │ │
│ │ returns { count: 1 } │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ new state │
│ ▼ │
│ Component re-renders with { count: 1 } │
│ │
│ The reducer is pure. The dispatch is stable. │
│ The state is always the result of the last reducer call. │
│ │
└──────────────────────────────────────────────────────────────┘
useState vs useReducer
┌──────────────────────────────────────────────────────────────┐
│ useState useReducer │
│ │
│ const [a, setA] = useState() const [state, dispatch] │
│ const [b, setB] = useState() = useReducer(reducer, │
│ const [c, setC] = useState() initialState) │
│ │
│ setA(x) dispatch({ type: 'a' }) │
│ setB(y) dispatch({ type: 'b' }) │
│ setC(z) dispatch({ type: 'c' }) │
│ │
│ Logic scattered across handlers Logic centralized in one │
│ function │
│ │
│ Test by rendering component Test by calling reducer │
│ │
│ Good for independent values Good for related fields │
│ │
└──────────────────────────────────────────────────────────────┘
Reducer with Context
┌──────────────────────────────────────────────────────────────┐
│ useReducer + CONTEXT │
│ │
│ FormProvider │
│ │ │
│ ├── useReducer(formReducer, initialFormState) │
│ │ → [state, dispatch] │
│ │ │
│ ├── <FormStateContext value={state}> │
│ │ └── provides state to consumers │
│ │ │
│ └── <FormDispatchContext value={dispatch}> │
│ └── provides dispatch to consumers │
│ │
│ Consumer A: useFormState() → re-renders on state change │
│ Consumer B: useFormDispatch() → never re-renders │
│ │
│ Splitting state and dispatch contexts means components │
│ that only dispatch do not re-render when state changes. │
│ │
└──────────────────────────────────────────────────────────────┘
The Decision Path
┌──────────────────────────────────────────────────────────────┐
│ useState OR useReducer? │
│ │
│ Is the state a single independent value? │
│ │ │
│ ├── Yes ──► useState │
│ │ │
│ └── No ──► Does the next state depend on the previous? │
│ │ │
│ ├── Yes ──► useReducer │
│ │ │
│ └── No ──► Are there multiple related fields? │
│ │ │
│ ├── Yes ──► useReducer │
│ │ │
│ └── No ──► Is the update logic │
│ complex or testable? │
│ │ │
│ ├── Yes ──► useReducer │
│ │ │
│ └── No ──► useState │
│ │
│ When in doubt, start with useState. Extract to a reducer │
│ when the update logic wants to be a function. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Reducer | Pure function (state, action) => nextState |
useReducer | Hook that returns [state, dispatch] |
| Dispatch | Stable function that sends an action to the reducer |
| Action | Object with a type and optional payload |
| Purity | Same input always produces same output; no mutation, no side effects |
| Lazy init | Third argument computes initial state once at mount |
| State + dispatch contexts | Splitting prevents re-renders for dispatch-only consumers |
| Testing | Reducers tested as pure functions, no rendering |
| Best for | Multiple related fields, complex transitions, undo/redo |
| Avoid for | Single independent values, trivial toggles |
Key takeaways:
- A reducer centralizes state transitions. Instead of scattering setters across event handlers, every transition goes through one function that enumerates the possible actions.
- The reducer must be pure. It returns new state and never mutates the old. It performs no side effects. The same state and action always produce the same next state.
- Dispatch is stable. React guarantees that the dispatch function does not change between renders. It can be passed to children and omitted from dependency arrays.
- Actions are named events. An action’s
typedescribes what happened, not what should change. The reducer decides what the action does to the state. - Lazy initialization computes the initial state once. The third argument to
useReduceris aninitfunction that receives the second argument and returns the initial state. - Reducers are testable in isolation. A reducer is a function. You call it with a state and an action and assert on the result. No rendering, no DOM, no simulation.
- Split state and dispatch contexts. When combining
useReducerwith context, provide state and dispatch in separate contexts so components that only dispatch do not re-render when state changes. - Start with
useState. Extract to a reducer when the update logic wants to be a function. The reducer pattern pays for its ceremony when the state has multiple related fields or complex transitions.
Remember: useReducer is not a replacement for useState — it is a tool for a specific kind of state. When the state has multiple related fields, when the next state depends on the previous state in complex ways, when the transitions need to be named and tested, the reducer pattern consolidates the logic into one pure function that is easier to reason about, easier to test, and easier to extend. The ceremony of reducers — the actions, the dispatch calls, the switch statement — is the cost of that consolidation. For simple state, that cost is not worth paying. For complex state, it is the difference between logic that is scattered and logic that is centralized. The reducer is the function that describes how the state changes. The dispatch is the mechanism that triggers it. The state is always the result of the last call.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!