useState vs useReducer: 상태가 복잡해지는 순간 선택해야 할 도구

크리스가 쇼핑몰의 장바구니 기능을 만들고 있다. 처음에는 단순했다. 상품 목록을 담을 배열 하나면 충분했다.
const [cart, setCart] = useState<Product[]>([]);그런데 요구사항이 늘어나기 시작한다.
어느새 크리스의 컴포넌트는 useState로 도배가 되고, 상태를 업데이트하는 코드는 여기저기 흩어져버렸다. 로딩을 끄는 것을 깜빡하거나, 에러 초기화를 놓치는 버그가 속출한다.
"상태 관리가 왜 이렇게 복잡하지? 한곳에서 모아서 처리할 수는 없을까?"
이때가 바로 useReducer를 꺼내 들어야 할 순간이다.
1. useState의 한계: 흩어진 로직
useState는 독립적인 값(숫자, 문자열, 단순 불리언)을 다룰 때 가장 좋다. 하지만 여러 상태가 서로 의존적으로 변할 때는 문제가 생긴다.
// ❌ 나쁜 예시: 상태 로직이 이벤트 핸들러 안에 숨어있다.
const handleAddProduct = (product) => {
setIsLoading(true);
setError(null);
api.addToCart(product)
.then((newCart) => {
setCart(newCart);
setTotal(calculateTotal(newCart)); // 총액 계산 로직이 여기에도 있고...
setIsLoading(false);
})
.catch((err) => {
setError(err.message);
setIsLoading(false);
});
};
const handleDeleteProduct = (id) => {
// 삭제할 때도 총액 계산 로직을 또 작성해야 함 (중복!)
// ...
};"무엇을 어떻게 바꿀지"에 대한 로직이 컴포넌트 내부 함수들에 파편화되어 있다. 코드를 읽는 사람은 setCart를 찾는 숨바꼭질을 해야 한다.
2. useReducer: 상태 관리의 중앙 통제실
useReducer는 상태 업데이트 로직을 컴포넌트 밖으로 분리해주는 도구다. 쉽게 말해 "회계 장부" 시스템과 같다.
이제 크리스의 장바구니 코드를 useReducer로 리팩토링 해보자.
1단계: Action과 State 정의 (TypeScript)
먼저 "무슨 일이 일어날 수 있는지"를 정의한다.
// types.ts
type CartState = {
cart: Product[];
total: number;
isLoading: boolean;
error: string | null;
};
// 발생할 수 있는 모든 사건(Action)을 나열
type CartAction =
| { type: 'ADD_START' }
| { type: 'ADD_SUCCESS'; payload: Product[] }
| { type: 'ADD_FAIL'; payload: string }
| { type: 'DELETE_ITEM'; payload: number }; // id2단계: Reducer 함수 작성 (로직 집약)
모든 상태 변경 로직이 이 함수 하나에 모인다.
// cartReducer.ts
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_START':
return { ...state, isLoading: true, error: null };
case 'ADD_SUCCESS':
const newCart = action.payload;
return {
...state,
isLoading: false,
cart: newCart,
total: calculateTotal(newCart) // 계산 로직도 여기서 한 번만!
};
case 'ADD_FAIL':
return { ...state, isLoading: false, error: action.payload };
default:
return state;
}
}3단계: 컴포넌트에서 사용 (Dispatch)
이제 컴포넌트는 로직을 몰라도 된다. 단지 요청(dispatch)만 보내면 된다.
// CartComponent.tsx
function CartComponent() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const handleAddProduct = (product) => {
dispatch({ type: 'ADD_START' }); // "시작했습니다" 보고
api.addToCart(product)
.then(res => dispatch({ type: 'ADD_SUCCESS', payload: res }))
.catch(err => dispatch({ type: 'ADD_FAIL', payload: err.message }));
};
return (
<div>
{state.isLoading && <Spinner />}
{state.error && <ErrorMsg msg={state.error} />}
</div>
);
}3. 언제 무엇을 써야 할까?
그렇다면 무조건 useReducer가 좋을까? 아니다. 코드가 길어지는 단점이 있다. 명확한 기준을 세워보자.
| 기준 | useState | useReducer |
| 데이터 타입 | 원시값 (number, string, boolean) | 복잡한 객체 (Object, Array) |
| 상태 개수 | 1~2개의 독립적인 상태 | 서로 연관된 여러 상태 |
| 로직 위치 | 이벤트 핸들러 내부 | 컴포넌트 외부 (reducer 함수) |
| 의존성 | 이전 상태에 의존적이지 않음 | 다음 상태가 이전 상태에 강하게 의존함 |
✅ 크리스의 선택 기준
핵심 정리
이제 상태를 관리하는 법을 알았으니, 사용자와 상호작용하는 폼(Form)을 다룰 차례다. 입력을 다루는 두 가지 방식, 당신은 어느 쪽인가?
"제어 컴포넌트 vs 비제어 컴포넌트: React Hook Form이 렌더링을 줄이는 비결" 편에서 계속된다.