Reducer function definition and purpose
A reducer is a function that consolidates state update logic outside a component. It takes two arguments: the current state and an action object. It returns the next state. Reducers allow you to move state update logic from multiple scattered event handlers into a single function, making complex state management easier to read and maintain.
Action object structure and conventions
An action is a regular JavaScript object that describes what happened. By convention, it contains a string `type` field that describes what happened, and additional fields with information needed to handle that action. The type field is specific to the component. Example: {type: 'added', id: 1, text: 'task'}. An action object can have any shape, but this convention is recommended.
Three-step process to migrate from useState to useReducer
Step 1: Move from setting state to dispatching actions. Replace direct setState calls with dispatch calls that pass action objects describing what the user did. Step 2: Write a reducer function that takes current state and action, then returns the next state. Step 3: Use useReducer hook by importing it from React and replacing useState(initialState) with useReducer(reducerFunction, initialState), which returns [state, dispatch].
useReducer hook signature and return value
useReducer takes two arguments: a reducer function and an initial state. It returns an array with two elements: a stateful value (the current state) and a dispatch function (used to dispatch actions to the reducer).
Reducer must be a pure function
Reducers must be pure functions. They run during rendering and must produce the same output for the same inputs. They should not send requests, schedule timeouts, or perform side effects. They should update objects and arrays without mutations. Similar to state updater functions, reducers are queued until the next render.
Action design best practice: single user interaction per action
Each action should describe a single user interaction, even if it leads to multiple changes in the data. For example, if a user presses Reset on a form with five fields, dispatch one reset_form action rather than five separate set_field actions. This makes action logs clear and helps with debugging by showing what interactions happened in what order.
Reducer switch statement pattern with curly braces
It is recommended to use switch statements inside reducers. Wrap each case block in curly braces {} to prevent variables declared in different cases from clashing with each other. A case should usually end with a return statement. If you forget to return, the code will fall through to the next case, which can lead to mistakes.
Why reducers are called reducers
Reducers are named after the Array.reduce() operation. Just as reduce() takes an array and accumulates a single value from many items, React reducers take the state so far and an action, then return the next state. Both follow the same pattern of accumulating results over time: reduce() accumulates values, while useReducer accumulates actions into state.
useState vs useReducer comparison
useState requires less code upfront but state updates can become scattered across event handlers. useReducer requires writing more code initially (reducer function plus dispatch calls) but helps if many event handlers modify state similarly. useState is easier to read for simple state updates. useReducer is better for debugging (can log all state updates in one place), testing (pure function can be tested separately), and complex state logic. Both are equivalent and can be mixed in the same component.
useImmerReducer for mutating reducer style
useImmerReducer from the use-immer library allows writing reducers using a mutating style. Instead of spreading objects/arrays and creating new ones, you can use push() or direct array assignment on a draft object. Under the hood, Immer creates a copy of your state with the changes you made to the draft. useImmerReducer takes the same arguments as useReducer (reducer function and initial state) and returns [state, dispatch] the same way.
Reducer can be declared inside or outside component
A reducer function can be declared at the bottom of the component file or in a separate file and imported. Because the reducer function takes state as an argument rather than accessing it from closure, it can be declared outside the component. Declaring it outside decreases indentation level and can make code easier to read.
Example: Task app with useReducer
```js
import { useReducer } from 'react';
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
```
This example demonstrates converting useState with multiple event handlers (handleAddTask, handleChangeTask, handleDeleteTask) to useReducer with a single tasksReducer function that handles all three action types.
useReducer consolidates multiple state updates into one reducer function
For components with many state updates spread across many event handlers, consolidate state update logic outside your component in a single reducer function. Event handlers become concise by only specifying the user 'action', while the reducer function at the bottom specifies how the state should update in response to each action type.
Reducer function structure: takes current state and action, returns new state
A reducer function takes two parameters: the current state and an action object, and returns the new state. It typically uses a switch statement on action.type to determine how to update state. The reducer must be a pure function with no side effects.
Example: useReducer for task management
import { useReducer } from 'react';
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
This example shows using useReducer to manage a task list with add, change, and delete actions.
Combining reducers and context for complex state management
Reducers and context can be combined together to manage state of a complex screen. A parent component with complex state manages it with a reducer. Other components anywhere deep in the tree can read its state via context and dispatch actions to update that state using custom hooks.
Example: TasksProvider combining useReducer and Context
import { createContext, useContext, useReducer } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
This example shows creating a provider component that manages tasks with a reducer and exposes custom hooks for components to access state and dispatch actions.
Combining reducer with context overview
Reducers consolidate a component's state update logic, and context lets you pass information deep down to other components. By combining them, you avoid prop drilling—passing state and dispatch through many intermediate components. This pattern is useful for managing state in complex screens.
Example: TasksProvider component with reducer
import { createContext, useContext, useReducer } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
const initialTasks = [
{ id: 0, text: 'Philosopher's Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];
Benefits of combining reducer with context
This pattern avoids prop drilling by removing the need to pass state and event handlers down through multiple levels of components. The state still lives in the top-level component using useReducer, but any component in the tree can read the state and dispatch actions through context. This keeps components clean and focused on display logic rather than data management.
Multiple context-reducer pairs in large apps
As an app grows, you can have many context-reducer pairs (e.g., for different features like tasks, notifications, user settings). Each pair manages a separate domain of state. This is a powerful way to scale the app and lift state up without prop drilling, allowing deep access to the data throughout the component tree.
useReducer typing with TypeScript
Define the reducer state as an interface, define action types as a discriminated union, provide the type for the initial state, and type the reducer function parameters and return value. The types for the reducer function are inferred from the initial state, or you can optionally provide a type argument to useReducer.
Complete useReducer TypeScript example
import {useReducer} from 'react';
interface State {
count: number
};
type CounterAction =
| { type: "reset" }
| { type: "setCount"; value: State["count"] }
const initialState: State = { count: 0 };
function stateReducer(state: State, action: CounterAction): State {
switch (action.type) {
case "reset":
return initialState;
case "setCount":
return { ...state, count: action.value };
default:
throw new Error("Unknown action");
}
}
export default function App() {
const [state, dispatch] = useReducer(stateReducer, initialState);
const addFive = () => dispatch({ type: "setCount", value: state.count + 5 });
const reset = () => dispatch({ type: "reset" });
return (
<div>
<h1>Welcome to my counter</h1>
<p>Count: {state.count}</p>
<button onClick={addFive}>Add 5</button>
<button onClick={reset}>Reset</button>
</div>
);
}