Lesson 2.3

The Reducer Pattern: The Foundation of useReducer

Every complex tool is built on a simple foundation. Just as a sentence is built from words and a house from bricks, React’s complex state management is built on functional programming principles — and at the center of it sits the reducer pattern.

What you'll learn
  • How the reducer pattern works in plain JavaScript with Array.prototype.reduce
  • How React’s useReducer models state changes over time instead of over arrays
  • How to design reducers and actions for richer, multi-field state
  • How useReducer cleanly separates logic (reducer) from view (component)
  • When to reach for useReducer instead of useState in real-world components

1. The JavaScript Foundation

Before we write a single line of React with useReducer, we need to understand the JavaScript mechanism it is built on: the reducer pattern, as seen with Array.prototype.reduce.

Imagine you are building a simple e‑commerce cart. You have an array of items, each with a price:

javascript
const cart = [
  { item: "Book", price: 10 },
  { item: "Pen", price: 2 },
  { item: "Notebook", price: 5 },
]

A straightforward way to calculate the total is to use a loop that mutates an external variable:

javascript
let total = 0 // External state

cart.forEach(product => {
  total += product.price // Mutation
})

console.log(total) // 17

While this works, it is impure: the function that processes each product reads and writes total, a variable that lives outside of it. In large applications, this kind of scattered mutation makes it hard to reason about where and how state changes.

The reducer pattern solves this by keeping state and transformation logic inside a pure function:

javascript
const cart = [
  { item: "Book", price: 10 },
  { item: "Pen", price: 2 },
  { item: "Notebook", price: 5 },
]

// The reducer function:
// accepts current state (accumulatedTotal) and the current item (product)
function cartReducer(accumulatedTotal, product) {
  return accumulatedTotal + product.price
}

const initialTotal = 0
const total = cart.reduce(cartReducer, initialTotal)

console.log(total) // 17

This cartReducer is pure: it doesn’t read or write anything outside itself. It receives the current state (the accumulator), the current value (product), and returns the next state. That’s the entire reducer pattern in one line:

nextState = reducer(currentState, value)

2. The React Shift: State Over Time

In JavaScript, reduce walks over an array of values that all exist at once. In React, useReducer walks over a sequence of actions that happen over time.

Instead of an array of products, imagine an ongoing stream of user interactions: clicks, keystrokes, toggles, form submissions. Each interaction is modeled as an action. The reducer takes the current state of your UI and the latest action, and returns the next state:

nextState = reducer(currentState, action)

React’s useReducer hook mirrors Array.prototype.reduce, but instead of iterating over an array, it reacts to dispatched actions over the lifetime of the component:

tsx
const [state, dispatch] = React.useReducer(reducer, initialState)

// state:       current snapshot of your data
// dispatch:    function you call to send an action
// reducer:     pure function deciding how state changes
// initialState: starting value for the state machine

3. Real-World Example: An RPG Character

Let’s move beyond simple counters. Imagine a game HUD for a character whose state includes Health (HP), Mana (MP), and a status flag for whether the character is alive.

We’ll handle three kinds of actions: taking damage, healing, and casting a spell (which spends mana). The reducer becomes the single source of truth for how these actions affect state.

tsx
const initialState = {
  hp: 100,
  mp: 50,
  isAlive: true,
}

type GameAction =
  | { type: "TAKE_DAMAGE"; amount: number }
  | { type: "HEAL" }
  | { type: "CAST_SPELL" }

function gameReducer(state: typeof initialState, action: GameAction) {
  // If the character is dead, most actions stop having any effect.
  if (!state.isAlive && action.type !== "HEAL") {
    return state
  }

  switch (action.type) {
    case "TAKE_DAMAGE": {
      const newHp = state.hp - action.amount
      return {
        ...state,
        hp: newHp > 0 ? newHp : 0,
        isAlive: newHp > 0,
      }
    }

    case "HEAL": {
      return {
        ...state,
        hp: Math.min(state.hp + 20, 100), // Cap HP at 100
        isAlive: true,
      }
    }

    case "CAST_SPELL": {
      if (state.mp < 10) return state // Not enough mana
      return {
        ...state,
        mp: state.mp - 10,
      }
    }

    default: {
      return state
    }
  }
}

Notice how the reducer function is self‑contained: given a state and an action, it returns a new state. It doesn’t know anything about buttons, clicks, or the DOM.

The component is where user interactions are translated into actions. It doesn’t do any state math itself — it simply dispatches what happened:

tsx
import React from "react"

export default function CharacterProfile() {
  const [state, dispatch] = React.useReducer(gameReducer, initialState)

  return (
    <div className="card space-y-4">
      <h2>
        Hero Status:{" "}
        <span className={state.isAlive ? "text-emerald-500" : "text-red-500"}>
          {state.isAlive ? "Alive" : "Fallen"}
        </span>
      </h2>
      <p>
        HP: {state.hp} | MP: {state.mp}
      </p>

      <div className="flex gap-2">
        <button
          onClick={() => dispatch({ type: "TAKE_DAMAGE", amount: 15 })}
        >
          Attack Enemy (take 15 damage)
        </button>
        <button onClick={() => dispatch({ type: "CAST_SPELL" })}>
          Cast Fireball (cost 10 MP)
        </button>
        <button onClick={() => dispatch({ type: "HEAL" })}>
          Drink Potion (+20 HP)
        </button>
      </div>
    </div>
  )
}

This clean separation — components dispatching actions, reducers deciding how state evolves — scales much better than sprinkling setState calls and ad‑hoc logic across your tree.

4. When to Use useReducer vs useState

Scenario A: Complex, Related State Transitions

Data fetching is a classic case: you often track loading, error, and data. These three pieces of state are tightly coupled — you shouldn’t be loading and showing an error at the same time.

tsx
// With separate useState calls, you manually juggle invariants:
setLoading(false)
setError(null)
setData(result)

With a reducer, you instead describe what happened, and let the reducer enforce the correct combination of flags:

tsx
type FetchState<T> = {
  loading: boolean
  error: string | null
  data: T | null
}

type FetchAction<T> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; payload: T }
  | { type: "FETCH_ERROR"; error: string }

function fetchReducer<T>(
  state: FetchState<T>,
  action: FetchAction<T>,
): FetchState<T> {
  switch (action.type) {
    case "FETCH_START":
      return { loading: true, error: null, data: null }
    case "FETCH_SUCCESS":
      return { loading: false, error: null, data: action.payload }
    case "FETCH_ERROR":
      return { loading: false, error: action.error, data: null }
    default:
      return state
  }
}

// Somewhere in a component:
// dispatch({ type: "FETCH_SUCCESS", payload: result })

Scenario B: Escaping the useEffect Dependency Trap

Consider a traffic light that cycles Green → Yellow → Red → Green every two seconds. A naive useState implementation often leads to an effect that restarts its own interval on every render:

tsx
// ❌ Problematic: effect depends on currentLight, so it tears
function TrafficLightWithState() {
  const [currentLight, setCurrentLight] = React.useState<"green" | "yellow" | "red">("green")

  React.useEffect(() => {
    const id = window.setInterval(() => {
      setCurrentLight((prev) =>
        prev === "green" ? "yellow" : prev === "yellow" ? "red" : "green",
      )
    }, 2000)

    return () => window.clearInterval(id)
  }, [currentLight]) // Effect restarts every time the light changes

  return <div className={`light ${currentLight}`} />
}

Because currentLight is in the dependency array, the effect tears down and recreates the interval on every tick. That can introduce subtle timing bugs and unnecessary work.

With useReducer, dispatch is stable (it never changes), so your effect can run once and simply dispatch a generic “next” action:

tsx
type Light = "green" | "yellow" | "red"

type LightAction = { type: "NEXT_LIGHT" }

function lightReducer(state: Light, _action: LightAction): Light {
  if (state === "green") return "yellow"
  if (state === "yellow") return "red"
  return "green"
}

function TrafficLight() {
  const [light, dispatch] = React.useReducer(lightReducer, "green")

  React.useEffect(() => {
    const id = window.setInterval(() => {
      // We don't care what the current light is here,
      // we just say: move to the next state.
      dispatch({ type: "NEXT_LIGHT" })
    }, 2000)

    return () => window.clearInterval(id)
  }, []) // ✅ Stable: effect runs only once

  return <div className={`light ${light}`} />
}

This pattern — keep state transitions in a reducer, and let effects and event handlers only dispatch actions — leads to predictable behavior and simpler dependency arrays.

Summary

useReducer is not just a fancier useState; it is a way of organizing state transitions around a pure reducer function so that your components only describe what happened.

By building on the same reducer pattern you already know from Array.prototype.reduce, it gives you a natural tool for modeling complex, interdependent state and avoiding fragile webs of setState calls.

Whenever your state has multiple fields that change together, represents a state machine, or suffers from useEffect dependency issues, it’s worth asking: would this be clearer as a reducer?