Learning
레슨 10 / 12·4개 토픽

useReducer와 고급 상태 관리

useReducer란?

useReducer는 복잡한 상태 로직을 관리할 때 useState보다 적합한 Hook입니다. 상태 업데이트 로직을 reducer 함수로 분리하여 예측 가능하고 테스트하기 쉬운 상태 관리를 구현합니다. 여러 값이 서로 관련된 상태를 다룰 때 특히 유용합니다.

  • useState — 단순한 값(토글, 카운터, 입력 필드) 관리에 적합
  • useReducer — 복잡한 객체 상태, 여러 액션 타입, 이전 상태에 의존하는 업데이트에 적합

Reducer 패턴 기초

tsx
import { useReducer } from 'react';

// 1. 상태 타입 정의
interface CounterState {
  count: number;
  step: number;
}

// 2. 액션 타입 정의
type CounterAction =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' }
  | { type: 'SET_STEP'; payload: number };

// 3. Reducer 함수 — 순수 함수로 작성
function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step };
    case 'DECREMENT':
      return { ...state, count: state.count - state.step };
    case 'RESET':
      return { ...state, count: 0 };
    case 'SET_STEP':
      return { ...state, step: action.payload };
    default:
      return state;
  }
}

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

  return (
    <div>
      <p>카운트: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>초기화</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'SET_STEP', payload: Number(e.target.value) })}
      />
    </div>
  );
}

실전: 장바구니 상태 관리

복잡한 상태 관리의 대표적인 예시인 장바구니를 useReducer로 구현합니다. 상품 추가, 수량 변경, 삭제, 전체 비우기 등 다양한 액션을 체계적으로 관리합니다.

tsx
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  totalPrice: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: number }
  | { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } }
  | { type: 'CLEAR_CART' };

function calcTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      const items = existing
        ? state.items.map(i =>
            i.id === action.payload.id ? { ...i, quantity: i.quantity + 1 } : i
          )
        : [...state.items, { ...action.payload, quantity: 1 }];
      return { items, totalPrice: calcTotal(items) };
    }
    case 'REMOVE_ITEM': {
      const items = state.items.filter(i => i.id !== action.payload);
      return { items, totalPrice: calcTotal(items) };
    }
    case 'UPDATE_QUANTITY': {
      const items = state.items.map(i =>
        i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
      );
      return { items, totalPrice: calcTotal(items) };
    }
    case 'CLEAR_CART':
      return { items: [], totalPrice: 0 };
    default:
      return state;
  }
}

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], totalPrice: 0 });

  return (
    <div>
      <h2>장바구니 ({cart.items.length}개)</h2>
      {cart.items.map(item => (
        <div key={item.id}>
          <span>{item.name} — {item.price.toLocaleString()}원</span>
          <input
            type="number"
            min={1}
            value={item.quantity}
            onChange={e =>
              dispatch({ type: 'UPDATE_QUANTITY', payload: { id: item.id, quantity: +e.target.value } })
            }
          />
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>삭제</button>
        </div>
      ))}
      <p>합계: {cart.totalPrice.toLocaleString()}원</p>
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>비우기</button>
    </div>
  );
}

useReducer + useContext 결합

useReduceruseContext와 결합하면 전역 상태 관리를 구현할 수 있습니다. 상태와 dispatch를 Context로 제공하면 어떤 깊이의 컴포넌트에서든 상태를 읽고 업데이트할 수 있습니다.

tsx
import { createContext, useContext, useReducer, ReactNode } from 'react';

// Context 생성
const CartContext = createContext<{
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
} | null>(null);

// Provider 컴포넌트
function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], totalPrice: 0 });
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

// 커스텀 Hook
function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart는 CartProvider 안에서 사용해야 합니다.');
  return context;
}

// 어떤 컴포넌트에서든 사용 가능
function AddToCartButton({ product }: { product: Omit<CartItem, 'quantity'> }) {
  const { dispatch } = useCart();
  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
      장바구니 담기
    </button>
  );
}

function CartTotal() {
  const { state } = useCart();
  return <p>합계: {state.totalPrice.toLocaleString()}원</p>;
}
💡

useReducer의 reducer 함수는 반드시 순수 함수여야 합니다. 같은 state와 action이 주어지면 항상 같은 결과를 반환해야 하며, API 호출이나 타이머 같은 사이드 이펙트는 포함하지 않습니다.