C
React/Hooks/Lesson 10

useReducer — When useState Reaches Its Limits, the Foundation of Redux

35 min·theory
This chapter
2/5
TypeScript

useReducer — When useState Reaches Its Limits, the Foundation of Redux

💡 Why Should You Learn This? — The Moment useState Gets Messy

🎯 The moment you have 5–6 `useState` calls piled up in a single component and they start influencing each other — the state-update code becomes increasingly fragile with every touch.
💼 `useReducer` consolidates those states into **a single object + dispatch(action) pattern**. Every action and the change it produces is declared in the reducer function, making the logic fully traceable.
Redux Toolkit, Zustand, and `useActionState` (React 19) all share the same mental model as `useReducer`. Understanding this hook is the natural on-ramp to all of them.
🔗 A classic interview question: 'What is the criterion for choosing between `useState` and `useReducer`?' — consider the number of state values, their complexity, and whether updates originate from multiple places.
🏢 실무에서는
Shopping carts (add item, change quantity, remove, clear all), form wizards (next, back, save, validate), collaborative canvases (add shape, move, copy, undo) — all of these fall squarely in `useReducer` territory. No matter how many action types grow, you simply add a new branch to the single reducer.

reducer · action · dispatch — 3 Key Concepts

1. Signature

ts
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer: a pure function of the form (state, action) => newState
  • dispatch(action): calls the reducer to update state

2. Defining action types with a Discriminated Union

ts
type State = { items: CartItem[] };
type Action =
  | { type: 'add'; item: CartItem }
  | { type: 'remove'; id: number }
  | { type: 'setQty'; id: number; qty: number }
  | { type: 'clear' };

A union branched by the type field (tag). Inside a switch statement, the additional fields of action are inferred precisely for each case.

3. reducer — a pure function, never mutate

ts
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { items: [...state.items, action.item] };
    case 'remove':
      return { items: state.items.filter(i => i.id !== action.id) };
    case 'clear':
      return { items: [] };
    default: {
      const _: never = action; // exhaustive check
      return state;
    }
  }
}

4. Choosing between useState and useReducer

SituationRecommended
1–2 independent simple valuesuseState
Multiple fields inside an object change togetheruseReducer
Update logic is 5 or more linesuseReducer
The same action is dispatched from multiple placesuseReducer
Undo/Redo is neededuseReducer
💻 🅰️ useState — Update Logic Scattered Around
// ❌ With useState only — separate handlers for each
import { useState } from 'react';

type CartItem = { id: number; name: string; qty: number };

export function Cart() {
  const [items, setItems] = useState<CartItem[]>([]);

  const add = (item: CartItem) => setItems([...items, item]);
  const remove = (id: number) => setItems(items.filter(i => i.id !== id));
  const setQty = (id: number, qty: number) =>
    setItems(items.map(i => i.id === id ? { ...i, qty } : i));
  const clear = () => setItems([]);

  // Adding filter, sort, sum, etc. would require another useState
  // → As state increases, the component becomes messier

  return <div>{/* ... */}</div>;
}
💻 🅱️ useReducer — Logic Centralized in One Reducer
// ✅ useReducer — all actions in one reducer
import { useReducer } from 'react';

type CartItem = { id: number; name: string; qty: number };
type State = { items: CartItem[]; filter: 'all' | 'active' };

type Action =
  | { type: 'add'; item: CartItem }
  | { type: 'remove'; id: number }
  | { type: 'setQty'; id: number; qty: number }
  | { type: 'setFilter'; filter: 'all' | 'active' }
  | { type: 'clear' };

const initial: State = { items: [], filter: 'all' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { ...state, items: [...state.items, action.item] };
    case 'remove':
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'setQty':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.id ? { ...i, qty: action.qty } : i
        ),
      };
    case 'setFilter':
      return { ...state, filter: action.filter };
    case 'clear':
      return { ...state, items: [] };
    default: {
      const _: never = action; // exhaustive
      return state;
    }
  }
}

export function Cart() {
  const [state, dispatch] = useReducer(reducer, initial);

  const visible = state.filter === 'all'
    ? state.items
    : state.items.filter(i => i.qty > 0);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'add', item: { id: Date.now(), name: 'Book', qty: 1 } })}>
        Add Book
      </button>
      <button onClick={() => dispatch({ type: 'clear' })}>Clear All</button>
      <ul>
        {visible.map(i => (
          <li key={i.id}>
            {i.name} ×
            <input
              type="number"
              value={i.qty}
              onChange={(e) => dispatch({ type: 'setQty', id: i.id, qty: Number(e.target.value) })}
            />
            <button onClick={() => dispatch({ type: 'remove', id: i.id })}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Advantages:
// - New action = 1 new case (centralized)
// - Reducer can be unit tested (pure function)
// - Naturally extends to Redux Toolkit · useActionState

💡 💡 useReducer Best Practices — 5 Tips

1. reducer must always be a pure function
Given the same (state, action), it must always return the same result. Side effects like fetch or setTimeout are forbidden.

2. Never mutate state

ts
// ❌ state.items.push(item); return state;
// ✅ return { ...state, items: [...state.items, item] };

3. Define action types with a Discriminated Union
Inside a switch, the additional fields of action are inferred precisely per case.

4. Exhaustive check with never

ts
default: { const _: never = action; return state; }

If you add a new type to Action but forget to handle the case, you get a compile-time error.

5. Context + useReducer = mini Redux
For small apps, this is often sufficient without needing Redux.

⚡ Try It Yourself — reducer Pattern

Simulates action handling in a shopping cart reducer.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

In what situations is useReducer a better fit than useState?
💡 The true value of `useReducer` shines when **multiple actions update the same state in a variety of ways**. Because all update logic is consolidated in one reducer, the code is easy to trace and test. For simple toggles or single inputs, `useState` is the lighter-weight choice.
Read this first: useEffect
useReducer — Foundation of Redux - React