The Fundamental Role of a Reducer in React: A Complete Guide
A reducer in React is a pure function that accepts the current state and an action object, then returns a new state, enabling predictable state transitions through the useReducer Hook.
The concept of a reducer in React is central to managing complex state logic within functional components. According to the facebook/react source code, the useReducer Hook leverages this pattern to provide a deterministic alternative to useState, particularly when state updates depend on previous values or involve intricate data transformations.
What Is a Reducer in React?
At its core, a reducer is a pure function with the signature (state, action) => newState. It receives the previous state and an action object (or any value), then computes and returns the next state without mutating the original inputs.
When you invoke the useReducer Hook, React creates a dispatch function that enqueues actions. During the next render cycle, React processes these queued actions by running the reducer with the current state and the action, producing the next state. This architecture ensures that all state transitions flow through a single, centralized function, making updates predictable and easy to test.
How React Implements the Reducer Pattern
The Dispatcher Architecture
Internally, React implements useReducer by delegating to a dispatcher that selects the correct implementation based on the component’s current phase. In packages/react/src/ReactHooks.js, the public hook simply forwards to this dispatcher:
// ReactHooks.js#L73-L80
export function useReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const dispatcher = resolveDispatcher();
return dispatcher.useReducer(reducer, initialArg, init);
}
This delegation allows React to choose between mounting, updating, or rerendering logic while exposing a stable API to developers.
Mounting and Updating State
The actual state management occurs in packages/react-reconciler/src/ReactFiberHooks.js. During the initial render, the dispatcher calls mountReducer, which creates a hook object, stores the initial state, and builds an update queue to hold pending actions:
// ReactFiberHooks.js#L56-L90 (conceptual)
function mountReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = mountWorkInProgressHook();
let initialState;
if (init !== undefined) {
initialState = init(initialArg);
} else {
initialState = initialArg;
}
hook.memoizedState = initialState;
// ... queue setup
return [hook.memoizedState, dispatch];
}
On subsequent renders, updateReducerImpl processes the queued actions through the same reducer function to produce the new state:
// ReactFiberHooks.js#L102-L118 (conceptual)
function updateReducerImpl<S, A>(
hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
// Process pending actions in queue
let newState = current.memoizedState;
// Apply reducer to each action
// ...
hook.memoizedState = newState;
return [hook.memoizedState, dispatch];
}
This architecture ensures that the reducer serves as the single source of truth for state transitions, regardless of when or how many actions are dispatched between renders.
Practical Example: Counter with useReducer
The following example demonstrates a basic counter implemented with useReducer. The reducer handles two action types, ensuring all state logic remains centralized and predictable:
import React, {useReducer} from 'react';
// Define the shape of the state and actions
type State = {count: number};
type Action = {type: 'increment'} | {type: 'decrement'};
// Pure reducer function
function counterReducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
return state;
}
}
export default function Counter() {
const [state, dispatch] = useReducer(counterReducer, {count: 0});
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({type: 'decrement'})}>‑</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</div>
);
}
The counterReducer receives the previous state and an action, returns the new state, and never mutates its inputs.
Advanced Pattern: Lazy Initialization
React's useReducer supports lazy initialization through an optional third argument. This pattern defers expensive initial state calculations until the initial render, preventing unnecessary work on subsequent renders. Internally, mountReducer checks for this init function and invokes it only once during the initial render.
function init(initialValue: number): {count: number} {
// Expensive calculation could go here
return {count: initialValue};
}
function reducer(state: {count: number}, action: {type: 'reset'}): {count: number} {
switch (action.type) {
case 'reset':
return init(0);
default:
return state;
}
}
export function LazyCounter() {
const [state, dispatch] = useReducer(reducer, 0, init);
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({type: 'reset'})}>Reset</button>
</>
);
}
The third argument (init) is called only once to compute the initial state, matching the internal logic in mountReducer where React checks for an init function and invokes it lazily.
Summary
- A reducer in React is a pure function
(state, action) => newStatethat centralizes state transition logic. - The
useReducerHook creates a dispatch function that queues actions; React processes these actions during the next render by running the reducer. - Internally, React delegates to a dispatcher that selects
mountReducerorupdateReducerImplbased on the component lifecycle, as seen inReactHooks.jsandReactFiberHooks.js. - Reducers enforce immutability and predictability, making state changes easier to test and debug compared to direct mutation.
- Lazy initialization via a third argument to
useReducerallows expensive initial state calculations to run only once during the initial render.
Frequently Asked Questions
What makes a reducer function "pure" in React?
A reducer is considered pure when it returns the same output given the same inputs (state and action) without causing side effects. It must not mutate the existing state object directly; instead, it returns a new state object. This determinism allows React to optimize rendering and makes the component's behavior predictable and easy to unit test.
How does useReducer differ from useState?
While useState is ideal for primitive values or simple updates, useReducer is better suited for complex state logic that involves multiple sub-values or depends on previous state. useReducer provides a dispatch function with a stable reference, preventing unnecessary re-renders when passing callbacks to child components. Additionally, useReducer supports lazy initialization, whereas useState requires calling a function to achieve similar behavior.
Can I use useReducer without Redux?
Yes, useReducer is a built-in React Hook that operates locally within a component without requiring Redux or any external library. It implements the same reducer pattern that Redux popularized—managing state through actions and pure functions—but scoped to a single component or passed down via props or context. This makes it ideal for local state management that follows Redux patterns without the boilerplate of a global store.
When should I choose useReducer over useState?
Choose useReducer when your state logic involves multiple related values, requires complex update logic (such as toggling items in arrays or merging objects), or when the next state depends heavily on the previous state. It is also preferable when you need to optimize performance by passing a stable dispatch function to deeply nested child components, avoiding the re-render issues common with inline callbacks used with useState.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →