Skip to content

React useReducer Explained — Complex State Management Made Simple

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about React useReducer Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

React useReducer is a hook for managing complex state logic where the next state depends on the previous state, using a reducer function and actions.

What You'll Learn

  • How useReducer differs from useState
  • How to write reducer functions
  • How to dispatch actions
  • How to combine useReducer with context
  • When to use useReducer vs useState

Why It Matters

useReducer makes state transitions explicit and predictable. Instead of scattered setState calls, you centralize state logic in a reducer function, making state changes easier to reason about, test, and debug.

Real-World Use

Durga Antivirus Pro uses useReducer for the scan engine state machine: idle, scanning, paused, complete, error. Each state has valid transitions, and the reducer enforces them.

flowchart LR
    A[Dispatch Action] --> B[Reducer Function]
    B --> C[Current State + Action]
    C --> D[New State]
    D --> E[Component Re-renders]
    style A fill:#3b82f6,color:#fff

Basic useReducer

Replace multiple useState with a single reducer:

import { useReducer } from "react";

// Define actions as constants
const INCREMENT = "INCREMENT";
const DECREMENT = "DECREMENT";
const RESET = "RESET";
const SET_VALUE = "SET_VALUE";

// Reducer function
function counterReducer(state, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    case RESET:
      return { count: 0 };
    case SET_VALUE:
      return { count: action.payload };
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: INCREMENT })}>+</button>
      <button onClick={() => dispatch({ type: DECREMENT })}>-</button>
      <button onClick={() => dispatch({ type: RESET })}>Reset</button>
      <button onClick={() => dispatch({ type: SET_VALUE, payload: 100 })}>
        Set to 100
      </button>
    </div>
  );
}

Expected output: Buttons that increment, decrement, reset, and set counter value. All state logic is centralized in the reducer.

useReducer takes a reducer function and initial state. It returns the current state and a dispatch function. Actions are objects with a type property and optional payload. The reducer computes the new state based on the action.

Complex State with useReducer

Manage multiple related state values:

import { useReducer } from "react";

const initialState = {
  items: [],
  status: "idle", // idle | loading | success | error
  error: null,
  selectedId: null,
};

function shoppingReducer(state, action) {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, status: "loading", error: null };
    case "FETCH_SUCCESS":
      return { ...state, status: "success", items: action.payload };
    case "FETCH_ERROR":
      return { ...state, status: "error", error: action.payload };
    case "ADD_ITEM":
      return {
        ...state,
        items: [...state.items, { id: Date.now(), ...action.payload }]
      };
    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    case "TOGGLE_PURCHASED":
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, purchased: !item.purchased }
            : item
        )
      };
    case "SELECT_ITEM":
      return { ...state, selectedId: action.payload };
    case "CLEAR_ERROR":
      return { ...state, error: null, status: "idle" };
    default:
      return state;
  }
}

function ShoppingApp() {
  const [state, dispatch] = useReducer(shoppingReducer, initialState);

  const fetchItems = async () => {
    dispatch({ type: "FETCH_START" });
    try {
      const res = await fetch("/api/shopping-list");
      const data = await res.json();
      dispatch({ type: "FETCH_SUCCESS", payload: data });
    } catch (err) {
      dispatch({ type: "FETCH_ERROR", payload: err.message });
    }
  };

  return (
    <div>
      {state.status === "loading" && <p>Loading...</p>}
      {state.status === "error" && (
        <p style={{ color: "red" }}>
          {state.error}
          <button onClick={() => dispatch({ type: "CLEAR_ERROR" })}>Dismiss</button>
        </p>
      )}
      <ul>
        {state.items.map(item => (
          <li key={item.id} style={{ textDecoration: item.purchased ? "line-through" : "none" }}>
            {item.name}
            <button onClick={() => dispatch({ type: "TOGGLE_PURCHASED", payload: item.id })}>
              Toggle
            </button>
            <button onClick={() => dispatch({ type: "REMOVE_ITEM", payload: item.id })}>
              Remove
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Expected output: A shopping list with loading, error, and data states. All transitions are explicit in the reducer.

This pattern keeps related state together. The status, items, error, and selection are all part of the same state machine. Instead of 4 separate useState calls, one reducer manages them.

useReducer with Context

Share the reducer across components:

import { createContext, useContext, useReducer } from "react";

const TodoContext = createContext();

function todoReducer(state, action) {
  switch (action.type) {
    case "ADD": return { ...state, todos: [...state.todos, action.payload] };
    case "TOGGLE": return {
      ...state,
      todos: state.todos.map(t =>
        t.id === action.payload ? { ...t, completed: !t.completed } : t
      )
    };
    case "DELETE": return {
      ...state,
      todos: state.todos.filter(t => t.id !== action.payload)
    };
    case "SET_FILTER": return { ...state, filter: action.payload };
    default: return state;
  }
}

function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: "all"
  });

  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
}

function useTodo() {
  const context = useContext(TodoContext);
  if (!context) throw new Error("useTodo must be used within TodoProvider");
  return context;
}

// Components
function AddTodo() {
  const { dispatch } = useTodo();
  const [text, setText] = useState("");

  return (
    <form onSubmit={e => {
      e.preventDefault();
      dispatch({ type: "ADD", payload: { id: Date.now(), text, completed: false } });
      setText("");
    }}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
}

function TodoList() {
  const { state, dispatch } = useTodo();
  const filtered = state.todos.filter(t => {
    if (state.filter === "active") return !t.completed;
    if (state.filter === "completed") return t.completed;
    return true;
  });

  return (
    <div>
      {filtered.map(todo => (
        <div key={todo.id}>
          <input type="checkbox" checked={todo.completed}
            onChange={() => dispatch({ type: "TOGGLE", payload: todo.id })} />
          {todo.text}
          <button onClick={() => dispatch({ type: "DELETE", payload: todo.id })}>X</button>
        </div>
      ))}
      <div>
        <button onClick={() => dispatch({ type: "SET_FILTER", payload: "all" })}>All</button>
        <button onClick={() => dispatch({ type: "SET_FILTER", payload: "active" })}>Active</button>
        <button onClick={() => dispatch({ type: "SET_FILTER", payload: "completed" })}>Completed</button>
      </div>
    </div>
  );
}

function App() {
  return (
    <TodoProvider>
      <AddTodo />
      <TodoList />
    </TodoProvider>
  );
}

Expected output: A todo app with add, toggle, delete, and filter. All state is managed through the reducer and shared via context.

useReducer + Context is the recommended pattern for shared state in medium-sized applications. It provides Redux-like architecture without external dependencies.

Initializer Function

Lazy initialization for expensive initial state:

import { useReducer } from "react";

function init(initialCount) {
  return { count: initialCount, lastUpdated: new Date().toISOString() };
}

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    case "decrement":
      return { ...state, count: state.count - 1 };
    default:
      return state;
  }
}

function Counter({ initialCount = 0 }) {
  const [state, dispatch] = useReducer(
    reducer,
    initialCount,
    init // Initializer function runs once
  );

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Last updated: {state.lastUpdated}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </div>
  );
}

Expected output: The counter initializes with the initialCount and a timestamp. The init function runs only once, not on every render.

The third argument to useReducer is an initializer function. It receives the second argument (initial value) and returns the actual initial state. This is useful for expensive computations or reading from localStorage.

Testing Reducers

Reducers are pure functions and easy to test:

// reducer.js
export function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM": {
      const existing = state.items.find(i => i.id === action.item.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.item.id ? { ...i, quantity: i.quantity + 1 } : i
          )
        };
      }
      return {
        ...state,
        items: [...state.items, { ...action.item, quantity: 1 }]
      };
    }
    case "REMOVE_ITEM":
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case "CLEAR":
      return { ...state, items: [] };
    default:
      return state;
  }
}

// reducer.test.js
import { cartReducer } from "./reducer";

const baseState = {
  items: [{ id: 1, name: "Apple", price: 1, quantity: 2 }]
};

describe("cartReducer", () => {
  test("adds a new item", () => {
    const result = cartReducer(baseState, {
      type: "ADD_ITEM",
      item: { id: 2, name: "Banana", price: 0.5 }
    });
    expect(result.items).toHaveLength(2);
    expect(result.items[1].quantity).toBe(1);
  });

  test("increments quantity for existing item", () => {
    const result = cartReducer(baseState, {
      type: "ADD_ITEM",
      item: { id: 1, name: "Apple", price: 1 }
    });
    expect(result.items).toHaveLength(1);
    expect(result.items[0].quantity).toBe(3);
  });

  test("removes an item", () => {
    const result = cartReducer(baseState, {
      type: "REMOVE_ITEM",
      id: 1
    });
    expect(result.items).toHaveLength(0);
  });

  test("clears the cart", () => {
    const result = cartReducer(baseState, { type: "CLEAR" });
    expect(result.items).toHaveLength(0);
  });
});

Expected output: All reducer tests pass. Testing reducers is simple because they are pure functions with no side effects.

Reducers are the most testable part of React state management. Given a state and an action, the reducer always returns the same new state. No mocks, no setup, no async handling needed.

Common Mistakes

  1. Mutating state in the reducer — Reducers must be pure. Spreading or returning new objects is required. Direct mutation breaks time-travel debugging and causes bugs.

  2. Throwing instead of returning state — If an unknown action type is dispatched, return the current state or throw explicitly. Returning undefined causes errors.

  3. Putting side effects in reducers — Reducers must be pure. API calls, random values, and timestamps belong outside the reducer, in event handlers or effects.

  4. Overusing useReducer for simple state — A single boolean or counter does not need a reducer. useState is simpler for independent values.

  5. Forgetting default case — Always include a default case that returns the current state. Otherwise, dispatching an unknown type breaks the app.

Practice Questions

  1. What is a reducer? A pure function that takes the current state and an action, and returns the new state.

  2. When should you use useReducer instead of useState? When state logic is complex, involves multiple sub-values, or when the next state depends heavily on the previous state.

  3. What does the dispatch function do? It sends an action to the reducer, which computes the new state and triggers a re-render.

  4. Can useReducer replace useState? Yes, but useState is simpler for independent values. useReducer shines for complex state.

  5. How do you share a reducer across components? Combine useReducer with React Context. Provide the state and dispatch through context.

Challenge

Build a FormStateReducer that manages a multi-step form. Track current step, form data per step, validation errors, submission status, and dirty fields. Each step dispatches actions to update specific sections. The form should support save-as-draft (stored in localStorage).

FAQ

Is useReducer just like Redux?

The reducer pattern is similar, but useReducer is built-in without the middleware, DevTools, or global store that Redux provides.

Can I have multiple useReducer in one component?

Yes, each useReducer is independent. Use separate reducers for unrelated state machines.

What is the initializer function?

A function passed as the third argument to useReducer that computes the initial state lazily.

How do I handle async actions with useReducer?

Dispatch multiple actions: "FETCH_START", "FETCH_SUCCESS", "FETCH_ERROR". Handle side effects in a custom hook or useEffect.

Can I use useReducer with TypeScript?

Yes, type the state and action with discriminated unions. Actions can be typed as { type: "INCREMENT" } | { type: "SET_VALUE", payload: number }.

Mini Project

Build a TaskBoardApp with useReducer managing three columns (todo, in-progress, done). The reducer handles: add task, move task between columns, reorder tasks within column, edit task, delete task, and set task priority. Combine with context so board, column, and card components can all dispatch actions. Add undo support by keeping a history stack in the reducer state.

What's Next

Continue with memo and performance optimization:

React Memo, React Callback, React Hooks

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro