Introduction to useReducer
The useReducer Hook is a React state-management Hook that lets you update state using a reducer function. Instead of defining separate state updates for every interaction, you describe an action and let the reducer determine the next state.
This approach is useful when state contains multiple related values, when several actions can update the same data, or when update logic becomes difficult to manage with multiple useState calls. React provides useReducer as part of its built-in Hooks API.
Core idea
The component dispatches an action, the reducer receives the current state and that action, and the reducer returns the next state. React then renders the component using the updated state.
Understanding the Reducer Pattern
A reducer is a function that receives the current state and an action, then returns the next state. It describes how state changes in response to a particular event.
function reducer(state, action) {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
default:
return state;
}
}
In this example, the reducer handles two actions. The default branch returns the existing state when an action is not recognized. For TypeScript applications, a discriminated union can make invalid action types easier to catch during development.
useReducer Syntax
Import useReducer from React and call it at the top level of your component. It returns the current state and a dispatch function.
const [state, dispatch] = useReducer(reducer, initialState);
- reducer: A function that calculates the next state from the current state and an action.
- initialState: The initial state value used when the Hook initializes.
- state: The current state available to the component.
- dispatch: A function used to send an action to the reducer.
React can also accept an optional third argument, an initialization function, when the initial state needs to be calculated from another value. This is useful for avoiding repeated initialization work during rendering.
Building a Counter with useReducer
A counter is a simple way to understand the relationship between state, actions, reducers, and dispatch. The reducer defines the allowed updates, while the component dispatches actions when the user clicks a button.
import { useReducer } from 'react';
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' };
function counterReducer(state: number, action: Action): number {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
case 'reset':
return 0;
}
}
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>
Increment
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
Decrement
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
</div>
);
}
Each button dispatches an action object. React passes that action and the current state to the reducer. The reducer returns the next number, and React updates the rendered count.
Understanding Actions
An action is a value describing what happened or what update is requested. Objects are commonly used because they can contain both a type and additional data.
dispatch({
type: 'set-count',
value: 10
});
dispatch({
type: 'add-item',
item: 'Keyboard'
});
Actions help separate user interactions from state-transition logic. The component does not need to know how each update is implemented; it only needs to dispatch the appropriate action.
A well-designed action should describe a meaningful event, such as item-added, form-submitted, or quantity-changed. Consistent action naming makes larger reducers easier to read and maintain.
Managing Multiple State Values
A reducer becomes especially useful when a component manages related values together. For example, a form may need to track the current input, submission status, and validation message. A single reducer can coordinate these updates.
type FormState = {
name: string;
status: 'idle' | 'submitting' | 'success';
error: string | null;
};
type FormAction =
| { type: 'name-changed'; value: string }
| { type: 'submitted' }
| { type: 'submission-succeeded' }
| { type: 'submission-failed'; message: string };
const initialFormState: FormState = {
name: '',
status: 'idle',
error: null
};
function formReducer(
state: FormState,
action: FormAction
): FormState {
switch (action.type) {
case 'name-changed':
return {
...state,
name: action.value,
error: null
};
case 'submitted':
return {
...state,
status: 'submitting',
error: null
};
case 'submission-succeeded':
return {
...state,
status: 'success'
};
case 'submission-failed':
return {
...state,
status: 'idle',
error: action.message
};
}
}
The reducer defines how each action affects the form. Returning a new object preserves the previous state and updates only the fields relevant to the action. In a real form, the component would dispatch these actions in response to input changes and submission results.
Updating Objects and Arrays Immutably
Reducers must not mutate the existing state. Instead, return a new object or array that represents the next state. React relies on state identity to determine whether an update represents a change.
type Todo = {
id: number;
text: string;
completed: boolean;
};
type TodoAction =
| { type: 'added'; todo: Todo }
| { type: 'toggled'; id: number }
| { type: 'removed'; id: number };
function todoReducer(
state: Todo[],
action: TodoAction
): Todo[] {
switch (action.type) {
case 'added':
return [...state, action.todo];
case 'toggled':
return state.map(todo =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
case 'removed':
return state.filter(todo => todo.id !== action.id);
}
}
The add action creates a new array, the toggle action creates a new array and a new object for the changed item, and the remove action filters out the selected item. The original state remains unchanged.
For a deeper understanding of immutable updates and rendering collections, see React State and React Lists and Keys.
Lazy Initialization with useReducer
Sometimes the initial state must be calculated from another value or prepared using a more expensive operation. The third argument to useReducer lets you provide an initializer function that calculates the initial state.
import { useReducer } from 'react';
type State = {
count: number;
};
type Action = { type: 'increment' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
}
}
function initialize(initialCount: number): State {
return { count: initialCount };
}
function Counter({ initialCount }: { initialCount: number }) {
const [state, dispatch] = useReducer(
reducer,
initialCount,
initialize
);
return (
<button onClick={() => dispatch({ type: 'increment' })}>
Count: {state.count}
</button>
);
}
React calls initialize to calculate the initial state rather than using the second argument directly. The initializer should be pure because React may call it more than once in development Strict Mode to help identify accidental side effects. Changing the initialCount prop after initialization does not automatically reset the reducer state.
useReducer with TypeScript
TypeScript works well with reducers because state and action types can be defined explicitly. A discriminated union uses the action's type field to narrow the available properties in each switch branch.
type State = {
count: number;
};
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; value: number };
const initialState: State = {
count: 0
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'set':
return { count: action.value };
}
}
When the action type is set, TypeScript knows that the action contains a numeric value. If you add another action, the compiler can help identify branches that need to be implemented, especially when an exhaustive-check pattern is used.
Learn more about typing props, state, events, and Hooks in React with TypeScript.
Combining useReducer with Context
Context and useReducer solve different problems and can be combined. The reducer manages state transitions, while Context makes the state or dispatch function available to descendant components without passing props through every intermediate component.
import {
createContext,
useContext,
useReducer,
type ReactNode,
type Dispatch
} from 'react';
type State = {
count: number;
};
type Action = { type: 'increment' } | { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'reset':
return { count: 0 };
}
}
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);
function CounterProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
function useCounterState() {
const state = useContext(StateContext);
if (state === null) {
throw new Error('useCounterState must be used within CounterProvider');
}
return state;
}
function useCounterDispatch() {
const dispatch = useContext(DispatchContext);
if (dispatch === null) {
throw new Error('useCounterDispatch must be used within CounterProvider');
}
return dispatch;
}
Separate contexts let components subscribe to the state or dispatch value independently. The dispatch function returned by useReducer has a stable identity, so components that consume only the dispatch context do not receive a changed dispatch value whenever reducer state changes.
Read React Context for more details about provider boundaries, context values, and consumers.
useReducer vs useState
| Aspect | useState | useReducer |
|---|---|---|
| Update approach | Set a new state value | Dispatch an action to a reducer |
| Best fit | Simple or independent state | Related state and multiple transitions |
| Logic location | Often near event handlers | Centralized in a reducer function |
| Complexity | Usually less setup | Requires state and action design |
Neither Hook is universally better. Choose the simplest approach that keeps the state transitions understandable. You can also use both Hooks in the same component for different kinds of state.
When Should You Use useReducer?
- Several actions update the same group of related state values.
- State transitions contain conditional or coordinated logic.
- You want to separate update rules from rendering and event handlers.
- You need predictable, testable state-transition logic.
- You want to share reducer-managed state through Context.
- A form, shopping cart, task list, or multi-step workflow has several meaningful state transitions.
For a simple toggle or a single input value, useState is often easier to read. Avoid introducing a reducer merely because a component has more than one state variable.
Common useReducer Mistakes
- Mutating state: Do not change existing objects or arrays. Return new values instead.
- Performing side effects inside the reducer: Reducers should be pure functions. Perform network requests and other side effects in event handlers or Effects, then dispatch the resulting action.
- Forgetting to return a state: Every supported action branch must return the next state.
- Dispatching the wrong action shape: Keep action types and payloads consistent with the reducer and TypeScript definitions.
- Expecting state to change immediately: Dispatch schedules an update; the current render's state value remains a snapshot.
- Using stale state in custom logic: Let the reducer calculate the next value from the state React passes to it rather than relying on an older captured value.
- Overcomplicating simple state: Use
useStatewhen it provides a clearer solution.
Best Practices for Reducers
- Keep reducers pure, deterministic, and free of side effects.
- Use descriptive action names that communicate meaningful events.
- Group state values that change together, but avoid creating a single reducer for unrelated data.
- Return new objects and arrays when updating nested state.
- Define action and state types explicitly when using TypeScript.
- Keep API calls, timers, and other side effects outside reducer functions.
- Test reducer functions independently by supplying a state and action and checking the returned state.
- Combine reducers with Context only when descendants need shared access to the state or dispatch function.
Testing a Reducer
Because a reducer is a pure function, it can be tested without rendering a React component. Supply a known state and action, then verify the result.
const initialState = { count: 0 };
const nextState = reducer(initialState, {
type: 'increment'
});
console.assert(nextState.count === 1);
console.assert(initialState.count === 0);
These assertions demonstrate that the reducer returns the expected next state without modifying the original state. In a real project, use a test framework such as Vitest or Jest to organize assertions and report failures.
Related React Topics
- React State — understand state snapshots and immutable updates.
- React Hooks — learn the rules and purpose of React Hooks.
- React Context — share reducer-managed state across a component tree.
- React Forms — manage input values, validation, and submission.
- React Lists and Keys — render and update collections correctly.
- React with TypeScript — type state, actions, props, and event handlers.
- React Testing — test components and application logic.