| |

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

ParameterMeaning
reducerPure function (state, action) => nextState
initialStateInitial state value
init (optional)Function that computes initial state from initialArg
Returns[state, dispatch]

Reducer Rules

RuleMeaning
PureSame input always produces same output
No mutationReturns new state, never modifies the old
No side effectsNo dispatch, no I/O, no setState
ExhaustiveHandles every action type; throws on unknown

Action Shape

FieldPurpose
typeString identifying the action
payload fieldsAny data the reducer needs
Conventionpayload or spread fields; pick one and stay consistent

useState vs useReducer

SituationuseStateuseReducer
Single value✅Overkill
Multiple related fieldsAwkward✅
Next state depends on previousPossible✅
Complex transitionsScatteredCentralized
Testable in isolationNoYes
Undo/redoImpracticalNatural
Simple toggle✅Overkill

Lazy Initialization

UsageBehavior
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

PitfallWhy It HappensFix
Reducer mutates stateConfusing reducers with settersReturn new objects with spread
Unknown action silently ignoredMissing default caseThrow on unknown actions
Initial state recomputed every renderPassing an object literalUse lazy initialization with init
All consumers re-render on state changeState and dispatch in one contextSplit into two contexts
Reducer performs side effectsMixing transitions with I/OMove side effects to event handlers or effects
Action shape inconsistentNo conventionPick payload or spread and stay consistent
Reducer too largeHandling every action in one functionSplit by domain or use multiple reducers
useReducer for trivial stateOver-applying the patternUse 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

ItemValue
ReducerPure function (state, action) => nextState
useReducerHook that returns [state, dispatch]
DispatchStable function that sends an action to the reducer
ActionObject with a type and optional payload
PuritySame input always produces same output; no mutation, no side effects
Lazy initThird argument computes initial state once at mount
State + dispatch contextsSplitting prevents re-renders for dispatch-only consumers
TestingReducers tested as pure functions, no rendering
Best forMultiple related fields, complex transitions, undo/redo
Avoid forSingle 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 type describes 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 useReducer is an init function 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 useReducer with 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!