useReducer Deep Dive: Actions, Dispatch, and the Reducer Pattern

useReducer is not just useState with extra steps. It enforces an explicit event-action model that makes state transitions auditable, testable, and easy to reason about as complexity grows.

June 7, 20263 min read1 / 2

useState works well for simple, independent values. When you have multiple pieces of related state — or multiple operations that modify the same state in different ways — the logic starts to scatter across several setter calls and the component becomes hard to follow.

useReducer is the alternative. It centralizes all state transitions into a single function, following the same pattern Redux popularized. That is where the name comes from.

TSX
const [state, dispatch] = useReducer(reducer, { count: 0 });

Instead of a value and a setter, you get the current state object and a dispatch function. The initial state is an object. The reducer function you pass in handles every possible transition.

Writing the Reducer Function

The reducer receives two arguments: the current state and an action. It returns the next state.

TSX
function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'double': return { count: state.count * 2 }; default: console.log('Unknown action type:', action.type); return state; } }

A few things to note here. The switch handles every named action type and returns a new state object for each. The default case is not optional — dispatching a type that has no handler would otherwise fail silently. Return the unchanged state and log the issue so it is visible during development.

Dispatching Actions

To trigger a state change, call dispatch with an action object that has a type property:

TSX
<button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> <button onClick={() => dispatch({ type: 'double' })}>×2</button>

None of the state logic lives in the component. The component describes what happened — a button was clicked, an action was taken. The reducer decides what the state becomes as a result. That separation is the entire point.

Passing Data with Actions

Actions can carry additional data as a payload:

TSX
dispatch({ type: 'setCount', payload: 42 }); // in the reducer: case 'setCount': return { count: action.payload };

This is the standard convention. The type identifies the action. The payload carries whatever data the reducer needs to compute the next state.

When useReducer Earns Its Place

For a counter with one value, useReducer is clearly more code than useState. The value shows when state grows.

Consider a user object with multiple fields:

TSX
const [state, dispatch] = useReducer(reducer, { name: '', email: '', address: '', isVerified: false, });

Every operation that touches this user — updating the email, marking it verified, resetting the form — lives inside one function. You dispatch a named action and the reducer handles it. No scattered setter calls. No risk of accidentally mixing updates to unrelated fields.

The reducer is also trivially testable. It is a pure function: same inputs, same outputs, no side effects. You can test every state transition in isolation without mounting a component.

TSX
expect(reducer({ count: 5 }, { type: 'increment' })).toEqual({ count: 6 }); expect(reducer({ count: 0 }, { type: 'decrement' })).toEqual({ count: -1 });

The larger and more interconnected your state, the more useReducer pays off over useState.

Practice what you just read.

Trace the ReducerBuild a Todo Reducer
2 exercises