React useReducer Explained with Practical Examples

Learn how the useReducer Hook manages state through a pure reducer function, how to dispatch actions, and when the pattern beats useState.

6 min read

The useReducer Hook in React manages state by running a pure reducer function you write. Instead of calling setter functions inside event handlers, you dispatch actions that describe what happened, and the reducer returns the next state. This keeps every state transition in one place, which pays off once updates start depending on each other.

A reducer is just a function

A reducer takes the current state and an action, then returns the next state. It has no access to props, refs, or other Hooks, so all of its behavior is visible from its two arguments.

index.jsindex.js
function reducer(state, action) {
  if (action.type === "incremented") {
    return { count: state.count + 1 };
  }
  return state;
}

When the action type is incremented, the reducer returns a new object with count one higher. Returning the same state unchanged tells React there is nothing new to render.

Connect the reducer with useReducer

useReducer accepts the reducer and an initial value, and returns two things: the current state and a dispatch function. Dispatch is how handlers hand an action to the reducer.

App.jsxApp.jsx
import { useReducer } from "react";
function reducer(count, action) {
  if (action.type === "incremented") return count + 1;
  return count;
}
export default function Counter() {
  const [count, dispatch] = useReducer(reducer, 0);
  return <button onClick={() => dispatch({ type: "incremented" })}>Count: {count}</button>;
}

Clicking the button dispatches an action with the type incremented. React runs the reducer with the current count and that action, stores the returned value, and re-renders the button with the new number.

Most reducers use a switch statement so each action type has its own case. A counter with increment, decrement, and reset actions shows how one reducer holds every transition for the same state.

index.jsindex.js
function reducer(count, action) {
  switch (action.type) {
    case "incremented":
      return count + 1;
    case "decremented":
      return count - 1;
    default:
      return count;
  }
}

Each case returns a new value for one user interaction, and every branch is visible in one place instead of spread across several handlers. The default case returns the current state for any action the reducer does not recognize.

Update objects and arrays without mutation

A reducer must replace objects and arrays, never change them in place. For a task list, adding a task returns a new array built with the spread operator.

index.jsindex.js
function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return [...tasks, { id: action.id, text: action.text }];
    case "removed":
      return tasks.filter((task) => task.id !== action.id);
    default:
      return tasks;
  }
}

The spread and filter calls produce fresh arrays, so React sees a different reference and re-renders the list. Mutating the existing tasks array would make React skip the update because the reference stays the same.

Initialize state lazily

When building the initial state is expensive, pass an initializer function as the third argument. React calls it only during the first render.

App.jsxApp.jsx
function createInitialCount() {
  return { count: 0, history: [] };
}
function reducer(state, action) {
  if (action.type === "incremented") return { ...state, count: state.count + 1 };
  return state;
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, null, createInitialCount);
  return <button onClick={() => dispatch({ type: "incremented" })}>Count: {state.count}</button>;
}

Pass createInitialCount itself, not createInitialCount(), so the work runs once. The null second argument means the initializer needs no extra data.

Mistakes to avoid

  • Mutating state in the reducer. Always return a new object or array.
  • Dispatching during render. Every dispatch schedules another render, so the component loops forever.
  • Forgetting to return state in a case. The next state becomes undefined.

Choose a reducer when updates get complex

A reducer adds boilerplate, so it is not automatically better than useState. When one independent value changes at a time, keep useState. When several fields move together, see how to write actions and reducers for the naming rules, then read the useState vs useReducer comparison to decide which fits.

Rune AI

Rune AI

Key Insights

  • useReducer manages state through a pure reducer function.
  • Handlers dispatch actions that describe what happened.
  • The reducer returns the next state without mutating it.
  • Pass an initializer as the third argument to avoid rebuilding initial state.
  • Keep useState for simple independent values.
RunePowered by Rune AI

Frequently Asked Questions

What does useReducer return?

It returns an array with two items: the current state and a dispatch function. Dispatch sends an action to your reducer, which returns the next state.

When should I use useReducer instead of useState?

Use a reducer when several pieces of state update together, when many handlers change state in similar ways, or when update logic gets hard to trace. Keep useState for simple independent values.

Why does my reducer run twice in development?

React Strict Mode calls reducers and initializers twice to help you find accidental impurity. This is development only and does not affect production.

Conclusion

useReducer moves state update logic out of your component and into one pure reducer. Handlers dispatch actions that describe what happened, and the reducer returns the next state without mutating the old one. Reach for it when state updates start to depend on each other.