useReducer Hook đŸ—ī¸

1. Introduction 👋

useReducer is React's Hook for managing complex state logic — especially when state transitions depend on multiple related values or well-defined actions. This tutorial covers everything from the basics of reducers to advanced patterns like combining useReducer with Context for scalable global state.

Information

This tutorial assumes familiarity with useState and useContext, covered in earlier tutorials.

2. What is useReducer? 🤔

useReducer is a Hook that manages state using a reducer function — a pure function that takes the current state and an action, and returns the next state.

Code Snippet

import { useReducer } from 'react';

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

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

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </div>
  );
}
>>"useReducer is a React Hook that lets you add a reducer to your component." — React Documentation

3. Why Use useReducer? 💡

  • Predictable Transitions: All state changes flow through one central function.
  • Complex State Logic: Handles interrelated state values more cleanly than multiple useState calls.
  • Easier Testing: Reducers are pure functions, making them simple to unit test in isolation.
  • Clear Action History: Actions describe what happened, improving debuggability.

4. useReducer vs useState âš”ī¸

AspectuseStateuseReducer
Best ForSimple, independent valuesComplex, interrelated state transitions
Update MechanismDirect setter callsDispatching descriptive actions
Logic LocationScattered across event handlersCentralized in one reducer function
TestabilityRequires rendering the componentReducer can be tested as a pure function

Best Practice

Start with useState. Reach for useReducer once state logic grows complex enough that managing it with several useState calls becomes unwieldy.

5. Understanding Reducers 🧠

A reducer is a pure function with the signature (state, action) => newState. Given the same inputs, it always produces the same output, with no side effects.

Code Snippet

function reducer(state, action) {
  // must be pure: no API calls, no mutations, no randomness
  switch (action.type) {
    case "reset":
      return initialState;
    default:
      return state;
  }
}

Important

Reducers must remain pure — never mutate the existing state object, perform side effects, or return different results for the same inputs.

6. Reducer Function âš™ī¸

Code Snippet

function todosReducer(state, action) {
  switch (action.type) {
    case "add":
      return [...state, { id: action.id, text: action.text, done: false }];
    case "toggle":
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case "remove":
      return state.filter((todo) => todo.id !== action.id);
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

Tip

Throwing an error in the default case helps catch typos in action types early, during development.

7. State đŸ“Ļ

The state managed by useReducer can be any value — a primitive, object, or array — but is most commonly an object or array representing more complex, structured data.

Code Snippet

const initialState = {
  todos: [],
  filter: "all",
  isLoading: false,
};

const [state, dispatch] = useReducer(reducer, initialState);

8. Actions đŸŽŦ

An action is a plain object describing what happened. By convention, it includes a type field, and optionally other data needed to compute the next state.

Code Snippet

dispatch({ type: "add", text: "Learn useReducer" });
dispatch({ type: "toggle", id: "todo-1" });
dispatch({ type: "setFilter", filter: "completed" });

Note

Actions describe intent ("add this todo"), not implementation — the reducer alone decides how the state actually changes.

9. Dispatch Function 📨

dispatch is the function returned alongside state that sends an action to the reducer, triggering a state update and a re-render.

Code Snippet

function TodoForm({ dispatch }) {
  const [text, setText] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: "add", text });
    setText("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
}

Tip

dispatch has a stable identity across renders, similar to a state setter function — it's safe to omit from dependency arrays.

10. Initial State 🌱

Code Snippet

const initialState = { count: 0 };

const [state, dispatch] = useReducer(reducer, initialState);

Note

Like useState, the initial state argument is used only during the first render.

11. Lazy Initialization 💤

useReducer accepts an optional third argument — an initializer function — for computing the initial state lazily, useful when the setup is expensive.

Code Snippet

function init(initialCount) {
  return { count: initialCount, history: [] };
}

function reducer(state, action) {
  // ...
}

const [state, dispatch] = useReducer(reducer, initialCountProp, init);

Tip

Passing an initializer function avoids recomputing an expensive initial state on every render, mirroring the lazy initialization pattern available in useState.

12. Action Types đŸˇī¸

Defining action types as string constants reduces the risk of typos and improves autocompletion, especially as the number of actions grows.

Code Snippet

const ACTIONS = {
  ADD_TODO: "add_todo",
  TOGGLE_TODO: "toggle_todo",
  REMOVE_TODO: "remove_todo",
};

function reducer(state, action) {
  switch (action.type) {
    case ACTIONS.ADD_TODO:
      return [...state, action.payload];
    // ...
  }
}

13. Action Payloads đŸ“Ļ

A common convention wraps extra action data inside a payload field, keeping the action object's shape consistent across different action types.

Code Snippet

dispatch({
  type: "add_todo",
  payload: { id: crypto.randomUUID(), text: "Learn reducers" },
});

function reducer(state, action) {
  switch (action.type) {
    case "add_todo":
      return [...state, action.payload];
    default:
      return state;
  }
}

14. Multiple Actions đŸ”ĸ

Code Snippet

function cartReducer(state, action) {
  switch (action.type) {
    case "add_item":
      return { ...state, items: [...state.items, action.payload] };
    case "remove_item":
      return { ...state, items: state.items.filter((i) => i.id !== action.payload.id) };
    case "clear_cart":
      return { ...state, items: [] };
    case "apply_discount":
      return { ...state, discount: action.payload };
    default:
      return state;
  }
}

15. Complex State Management đŸ›ī¸

useReducer shines when a single user action affects multiple related pieces of state at once, something that's awkward to express cleanly with several separate useState calls.

Code Snippet

function formReducer(state, action) {
  switch (action.type) {
    case "submit_start":
      return { ...state, isSubmitting: true, error: null };
    case "submit_success":
      return { ...state, isSubmitting: false, isSubmitted: true };
    case "submit_error":
      return { ...state, isSubmitting: false, error: action.payload };
    default:
      return state;
  }
}

16. Managing Objects 🧱

Code Snippet

function userReducer(state, action) {
  switch (action.type) {
    case "update_field":
      return { ...state, [action.field]: action.value };
    default:
      return state;
  }
}

// Usage
dispatch({ type: "update_field", field: "email", value: "new@email.com" });

Danger

Just like with useState, always return a new object from the reducer — mutating state directly prevents React from detecting the change.

17. Managing Arrays 📚

Code Snippet

function listReducer(state, action) {
  switch (action.type) {
    case "add":
      return [...state, action.payload];
    case "remove":
      return state.filter((item) => item.id !== action.payload.id);
    case "update":
      return state.map((item) =>
        item.id === action.payload.id ? { ...item, ...action.payload } : item
      );
    default:
      return state;
  }
}

18. Nested State Updates đŸĒ†

Code Snippet

function settingsReducer(state, action) {
  switch (action.type) {
    case "toggle_notification":
      return {
        ...state,
        notifications: {
          ...state.notifications,
          [action.channel]: !state.notifications[action.channel],
        },
      };
    default:
      return state;
  }
}

Tip

For deeply nested state, consider using immer's produce() function inside the reducer to write updates with simpler, mutable-looking syntax.

19. Combining Reducers 🔗

For large applications, splitting one large reducer into smaller, focused reducers — each handling a specific slice of state — improves readability and maintainability.

Code Snippet

function todosReducer(state, action) {
  switch (action.type) {
    case "add_todo": return [...state, action.payload];
    default: return state;
  }
}

function filterReducer(state, action) {
  switch (action.type) {
    case "set_filter": return action.payload;
    default: return state;
  }
}

function rootReducer(state, action) {
  return {
    todos: todosReducer(state.todos, action),
    filter: filterReducer(state.filter, action),
  };
}

20. Reducer Composition đŸ§Ŧ

Composing reducers means each smaller reducer only manages its own slice of the overall state, while a root reducer coordinates them together — a pattern borrowed directly from Redux.

Reference

This pattern scales well for larger applications, but for most components a single, well-organized reducer is simpler and sufficient.

21. useReducer with useContext 🌐

Combining useReducer with Context creates a lightweight, built-in pattern for sharing complex state and its dispatch function across many components.

Code Snippet

const TodosContext = createContext(null);

function TodosProvider({ children }) {
  const [state, dispatch] = useReducer(todosReducer, []);
  return (
    <TodosContext.Provider value={{ state, dispatch }}>
      {children}
    </TodosContext.Provider>
  );
}

function useTodos() {
  return useContext(TodosContext);
}

function AddTodoButton() {
  const { dispatch } = useTodos();
  return (
    <button onClick={() => dispatch({ type: "add_todo", payload: { text: "New" } })}>
      Add Todo
    </button>
  );
}

22. Global State Management 🌍

useReducer combined with Context can serve as a lightweight alternative to external state management libraries for moderately complex applications.

ApproachBest For
useReducer + ContextModerate complexity, no extra dependencies needed
Redux / ZustandLarge-scale apps needing middleware, devtools, or fine-grained subscriptions

23. Async Operations with Reducers âŗ

Reducers themselves must stay pure and synchronous — async logic like API calls belongs outside the reducer, typically in an effect or event handler that dispatches actions based on the result.

Code Snippet

function reducer(state, action) {
  switch (action.type) {
    case "fetch_start": return { ...state, loading: true, error: null };
    case "fetch_success": return { ...state, loading: false, data: action.payload };
    case "fetch_error": return { ...state, loading: false, error: action.payload };
    default: return state;
  }
}

function useUserData(userId) {
  const [state, dispatch] = useReducer(reducer, { loading: false, data: null, error: null });

  useEffect(() => {
    dispatch({ type: "fetch_start" });
    fetchUser(userId)
      .then((data) => dispatch({ type: "fetch_success", payload: data }))
      .catch((error) => dispatch({ type: "fetch_error", payload: error.message }));
  }, [userId]);

  return state;
}

Important

Never perform fetch() calls, timers, or other side effects directly inside a reducer function — dispatch actions representing the result instead.

24. Performance Considerations ⚡

  • dispatch has a stable identity, making it safe to pass down to deeply nested components without causing extra re-renders.
  • Combine with Context carefully — all consumers still re-render when the Provider's value changes.
  • Split state into multiple reducers/contexts if different parts update at very different frequencies.

Tip

Because dispatch never changes, passing it through Context instead of the full state (in a separate context) can help minimize unnecessary re-renders for components that only need to trigger actions.

25. TypeScript with useReducer 🔷

Code Snippet

interface State {
  count: number;
}

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; payload: number };

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.payload };
    default: return state;
  }
}

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

Tip

Modeling actions as a discriminated union (using a shared type field) gives you full type-checking and autocompletion inside each switch case.

26. Best Practices 🌟

  1. Keep reducer functions pure — no side effects, no mutations, no randomness.
  2. Use descriptive, past-tense-free action type names (e.g., "add_todo", not "todoAdded" or "addTodo!") consistently.
  3. Handle async logic outside the reducer, dispatching actions based on results.
  4. Split large reducers into smaller, composed reducers as complexity grows.
  5. Pair with Context when multiple components need access to the same state and dispatch function.

27. Common Mistakes đŸšĢ

  • Mutating state directly inside the reducer instead of returning a new object or array.
  • Performing side effects (API calls, timers) directly inside the reducer function.
  • Forgetting a default case, silently returning undefined for unrecognized actions.
  • Overusing useReducer for genuinely simple state that would be clearer as a useState call.
  • Dispatching actions with inconsistent or misspelled type strings, especially without constants.

Danger

A reducer that forgets to return state in its default case will silently reset the state to undefined whenever an unrecognized action is dispatched.

28. Frequently Asked Questions ❓

Question

When should I use useReducer instead of useState?

Answer

Reach for useReducer when state transitions are complex, involve multiple related sub-values, or when the next state depends on carefully coordinated logic based on the previous state.

Question

Can I perform API calls inside a reducer?

Answer

No. Reducers must stay pure and synchronous. Perform async operations elsewhere (like useEffect) and dispatch actions based on their results.

Question

Is useReducer a replacement for Redux?

Answer

For many small-to-medium applications, useReducer combined with Context covers similar needs. Redux still offers advantages like middleware, time-travel debugging, and fine-grained performance optimizations for very large applications.

29. Summary 📝

useReducer centralizes complex state logic into a single, predictable, pure function, making state transitions easier to reason about and test. Combined with Context, it forms a powerful, dependency-free pattern for managing shared application state at scale.

Summary

With useReducer covered, strong next steps include exploring Custom Hooks to package reducer-based logic for reuse, and comparing built-in solutions against dedicated state management libraries for very large applications.