레슨 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 결합
useReducer를 useContext와 결합하면 전역 상태 관리를 구현할 수 있습니다. 상태와 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 호출이나 타이머 같은 사이드 이펙트는 포함하지 않습니다.