Skip to content

Part 6.3 — useReducer for Complex State Transitions

Site Console Site Console
3 min read Updated Jan 14, 2026 Frontend Design 0 comments

As React apps grow, state stops being “a few booleans” and starts becoming a set of transitions.
That’s the moment useState begins to fight you—and useReducer starts to shine.

This post teaches you how to recognize that moment and how to use useReducer to model complex behavior cleanly.


1. When useState Is No Longer Enough

useState works well when:

  • state changes are simple

  • updates are isolated

  • logic fits in a setter

It breaks down when:

  • multiple state fields change together

  • updates depend on previous state

  • many events affect the same state

  • logic spreads across handlers

Example smell:

setLoading(true);
setError(null);
setItems([]);

Scattered state updates hide intent.


2. Think in Events, Not Setters

Reducers shift your mindset from how to update state to what happened.

Instead of:

setLoading(true);

You express:

dispatch({ type: 'LOAD_START' });

This makes intent explicit and debuggable.


3. A Realistic Example: Data Fetching State

Let’s model a list that loads data.

State shape:

type State = {
  status: 'idle' | 'loading' | 'success' | 'error';
  items: Item[];
  error?: string;
};

Actions:

type Action =
  | { type: 'LOAD_START' }
  | { type: 'LOAD_SUCCESS'; payload: Item[] }
  | { type: 'LOAD_ERROR'; error: string };

This alone clarifies behavior.


4. Writing the Reducer

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'LOAD_START':
      return { status: 'loading', items: [] };

    case 'LOAD_SUCCESS':
      return { status: 'success', items: action.payload };

    case 'LOAD_ERROR':
      return { status: 'error', items: [], error: action.error };

    default:
      return state;
  }
}

Notice:

  • no side effects

  • no async logic

  • no mutations

Reducers describe transitions, not processes.


5. Using the Reducer in a Component

const [state, dispatch] = React.useReducer(reducer, {
  status: 'idle',
  items: [],
});

React.useEffect(() => {
  dispatch({ type: 'LOAD_START' });

  fetch('/api/items')
    .then(res => res.json())
    .then(data =>
      dispatch({ type: 'LOAD_SUCCESS', payload: data }),
    )
    .catch(() =>
      dispatch({ type: 'LOAD_ERROR', error: 'Failed to load' }),
    );
}, []);

Now all state changes flow through one place.


6. Rendering from State, Not Flags

if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <Error message={state.error} />;

return <ItemList items={state.items} />;

This avoids:

  • conflicting booleans

  • impossible states

  • defensive UI logic

The reducer enforces valid combinations.


7. Keep Reducers Pure and Boring

Reducers must:

  • be synchronous

  • return new state

  • have no side effects

  • never call APIs

Side effects belong in:

  • event handlers

  • useEffect

  • async functions

This separation is what makes reducers predictable.


8. Split Reducers by Domain, Not Component Size

If a reducer grows too large, don’t split by file size—split by responsibility.

Example:

  • authReducer

  • itemsReducer

  • formReducer

Each reducer should model one domain.


9. Avoid Over-Engineering Reducers

You don’t need reducers for:

  • toggling a boolean

  • controlled inputs

  • local UI state

Use reducers when:

  • state transitions matter

  • multiple events affect the same data

  • clarity beats brevity

Reducers add structure—don’t add them prematurely.


10. Testing Reducers Is Trivial (and Powerful)

Reducers are pure functions.

it('handles LOAD_SUCCESS', () => {
  const initial: State = { status: 'loading', items: [] };

  const next = reducer(initial, {
    type: 'LOAD_SUCCESS',
    payload: [{ id: 1 }],
  });

  expect(next.status).toBe('success');
  expect(next.items.length).toBe(1);
});

This is cheap, fast, and reliable.


11. Reducers as Documentation

Reducers document:

  • what can happen

  • how the system responds

  • which states are valid

New teammates learn behavior by reading reducers—not tracing event handlers.


12. Common Reducer Smells

Watch for:

  • reducers doing async work

  • action types like SET_FIELD_X

  • deeply nested state updates

  • reducers growing without boundaries

These indicate missing architecture decisions.


13. Summary

You now know how to:

  • recognize when useReducer is appropriate

  • model state as transitions

  • design clear action types

  • keep reducers pure

  • simplify rendering logic

  • test state behavior easily

Related

Leave a comment

Sign in to leave a comment.

Comments