C
React/Hooks/Lesson 10

useReducer — useState 로 한계가 올 때, Redux 의 기반

35분·theory
이 챕터
2/5
TypeScript

useReducer — useState 로 한계가 올 때, Redux 의 기반

💡 왜 배워야 할까요? — useState 가 더러워지는 순간이 있다

🎯 useState 5~6개가 한 컴포넌트에 쌓이고, 그것들이 서로 영향을 주는 순간 — 상태 업데이트 코드가 손 댈수록 깨지기 쉬워집니다.
💼 useReducer 는 그런 상태들을 **하나의 객체 + dispatch(action) 패턴**으로 정리합니다. 어떤 액션이 어떤 변경을 만드는지 reducer 함수에 모두 적혀 있어 추적 가능.
Redux Toolkit·Zustand·useActionState(React 19) — 모두 useReducer 와 같은 사고방식. 이걸 알아야 그쪽으로 자연스럽게 넘어갑니다.
🔗 면접 단골 질문: 'useState 와 useReducer 의 선택 기준?' — 상태 개수·복잡도·여러 곳에서의 업데이트 여부.
🏢 실무에서는
쇼핑 카트(상품 추가·수량 변경·삭제·전체 비우기), 폼 위저드(다음·이전·저장·검증), 협업 캔버스(도형 추가·이동·복사·undo) — 모두 useReducer 의 영역. 액션 종류가 늘어도 reducer 한 곳에 분기를 추가하면 끝.

reducer · action · dispatch — 3가지 개념

1. 시그니처

ts
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer: (state, action) => newState 인 순수 함수
  • dispatch(action): reducer 를 호출해서 state 를 업데이트

2. Discriminated Union 으로 action 타입 정의

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

type 필드(태그)로 분기되는 union. switch 안에서 각 case 마다 action 의 추가 필드가 정확히 추론됩니다.

3. reducer — 순수 함수, 절대 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. useState vs useReducer 선택 기준

상황권장
독립적인 1~2개 단순 값useState
객체 안에 여러 필드가 함께 변함useReducer
업데이트 로직이 5줄 이상useReducer
여러 곳에서 같은 액션 dispatchuseReducer
Undo/Redo 가 필요useReducer
💻 🅰️ useState — 업데이트 로직이 흩어짐
// ❌ useState 만 — 핸들러마다 따로
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([]);

  // 필터·정렬·합계 등 추가하면 또 다른 useState
  // → state 가 늘수록 컴포넌트가 더러워짐

  return <div>{/* ... */}</div>;
}
💻 🅱️ useReducer — reducer 한 곳에 집중
// ✅ useReducer — 모든 액션이 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: '책', qty: 1 } })}>
        책 추가
      </button>
      <button onClick={() => dispatch({ type: 'clear' })}>전체 비우기</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 })}>삭제</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 장점:
// - 새 액션 = case 1개 추가 (한 곳에 집중)
// - reducer 만 단위 테스트 가능 (순수 함수)
// - Redux Toolkit · useActionState 로 자연스럽게 확장

💡 💡 useReducer 실전 5

1. reducer 는 반드시 순수 함수
같은 (state, action) 이면 항상 같은 결과. fetch·setTimeout 같은 side effect 금지.

2. state mutate 절대 금지

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

3. Discriminated Union 으로 action 타입
switch 안에서 case 별로 action 의 다른 필드들이 정확히 추론됨.

4. never 로 exhaustive check

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

Action 에 새 type 추가했는데 case 안 적었으면 컴파일 에러로 알려줌.

5. Context + useReducer = 미니 Redux
작은 앱에선 Redux 안 써도 충분.

⚡ 직접 실행해보기 — reducer 패턴

쇼핑카트 reducer 의 액션 처리를 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

useReducer 가 useState 보다 적합한 상황은?
💡 useReducer 의 가치는 **여러 액션이 같은 state 를 다양한 방식으로 업데이트** 할 때 빛납니다. 업데이트 로직이 reducer 한 곳에 모여 추적·테스트가 쉽습니다. 단순한 토글·입력은 useState 가 더 가볍습니다.
먼저 읽으면 좋은 개념: useEffect
useReducer — Redux 의 기반 - React