적용 환경: React 18+, TypeScript 5+
React에서 상태 관리를 시작할 때 대부분 useState를 사용합니다. 간단하고 직관적이며, 대부분의 상황에서 충분합니다. 그런데 프로젝트가 커지면서 한 컴포넌트에서 관리하는 상태가 늘어나고, 상태 간의 관계가 복잡해지면 useState만으로는 코드가 점점 다루기 어려워집니다.
Redux에서 영감을 받은 useReducer 훅은 상태 변경 로직을 한 곳에 모아 관리할 수 있게 해줍니다. "언제 useState 대신 useReducer를 써야 하는가"는 React 개발자라면 한 번쯤 고민해본적 있을 겁니다. 이 문서에서는 그 판단 기준과 실전 패턴을 다룹니다.
useState의 한계
useState가 힘을 발휘하지 못하는 상황은 대개 세 가지로 나눌 수 있습니다.
여러 상태가 함께 변해야 할 때
쇼핑몰의 장바구니를 생각해보겠습니다. 사용자가 상품 수량을 변경하면 해당 상품의 수량뿐만 아니라 소계, 할인 금액, 배송비, 총액이 모두 다시 계산되어야 합니다.
function Cart() {
const [items, setItems] = useState([]);
const [subtotal, setSubtotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [shipping, setShipping] = useState(0);
const [total, setTotal] = useState(0);
const updateQuantity = (itemId: string, newQuantity: number) => {
// 1. 상품 수량 업데이트
const newItems = items.map(item =>
item.id === itemId ? { ...item, quantity: newQuantity } : item
);
setItems(newItems);
// 2. 소계 재계산
const newSubtotal = newItems.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
setSubtotal(newSubtotal);
// 3. 할인 재계산 (10만원 이상이면 10% 할인)
const newDiscount = newSubtotal >= 100000 ? newSubtotal * 0.1 : 0;
setDiscount(newDiscount);
// 4. 배송비 재계산 (5만원 이상 무료)
const newShipping = newSubtotal >= 50000 ? 0 : 3000;
setShipping(newShipping);
// 5. 총액 계산
setTotal(newSubtotal - newDiscount + newShipping);
};
}다섯 번의 setState 호출이 필요합니다. 이 중 하나라도 빠뜨리면 화면에 잘못된 금액이 표시되며, 새로운 개발자가 "쿠폰 할인" 기능을 추가할 때 실수하기 딱 좋은 구조입니다.
상태 변경 로직이 여러 곳에 흩어질 때
위 예제에서 수량 변경 외에 "상품 삭제", "쿠폰 적용", "전체 비우기" 기능을 추가한다고 가정해보겠습니다. 각 함수마다 비슷한 재계산 로직이 중복되고, 할인 정책이 바뀌면 모든 함수를 찾아다니며 수정해야 합니다. 로직이 여러 곳에 흩어져 있으면 변경 사항을 일관되게 적용하기가 어렵습니다.
상태 변경을 추적하기 어려울 때
버그가 발생했을 때 "어떤 상태가, 언제, 왜 바뀌었는지"를 파악하기 어렵습니다. setState는 그저 새 값을 설정할 뿐, 변경 이유를 남기지 않기 때문입니다. 복잡한 상태 로직에서 디버깅은 상태 변경의 흐름을 추적하는 것부터 시작하는데, 여러 개의 독립적인 setState 호출은 이 추적을 어렵게 만듭니다.
useReducer의 동작 원리
useReducer는 상태 변경을 "액션"이라는 객체로 표현합니다. 모든 상태 변경 로직은 "리듀서"라는 단일 함수에 모이고, 액션의 type을 보면 무슨 일이 일어났는지 바로 알 수 있습니다.
기본 시그니처
const [state, dispatch] = useReducer(reducer, initialState, init?);| 인자 | 설명 |
|---|---|
reducer | (state, action) => newState 형태의 순수 함수 |
initialState | 초기 상태 값 |
init | (선택) 초기 상태를 계산하는 함수 |
useReducer는 현재 상태(state)와 액션을 보내는 함수(dispatch)를 반환합니다. 컴포넌트에서 dispatch({ type: 'ACTION_TYPE', payload: data })를 호출하면, React는 리듀서 함수를 실행하여 새 상태를 계산하고 컴포넌트를 리렌더링합니다.
리듀서는 순수 함수
리듀서 함수는 동일한 입력에 대해 항상 동일한 출력을 반환해야 합니다. API 호출, 타이머 설정, localStorage 접근 같은 사이드 이펙트는 리듀서 내부에서 실행하면 안 됩니다.
장바구니 예제를 useReducer로 변환
앞서 본 장바구니 예제를 useReducer로 다시 작성하면 다음과 같습니다.
interface CartState {
items: CartItem[];
subtotal: number;
discount: number;
shipping: number;
total: number;
}
const initialState: CartState = {
items: [],
subtotal: 0,
discount: 0,
shipping: 0,
total: 0,
};
type CartAction =
| { type: 'UPDATE_QUANTITY'; itemId: string; quantity: number }
| { type: 'REMOVE_ITEM'; itemId: string }
| { type: 'APPLY_COUPON'; coupon: string }
| { type: 'CLEAR_CART' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'UPDATE_QUANTITY': {
const newItems = state.items.map(item =>
item.id === action.itemId
? { ...item, quantity: action.quantity }
: item
);
return recalculateTotals({ ...state, items: newItems });
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(item => item.id !== action.itemId);
return recalculateTotals({ ...state, items: newItems });
}
case 'APPLY_COUPON': {
return recalculateTotals({ ...state, coupon: action.coupon });
}
case 'CLEAR_CART': {
return initialState;
}
default:
return state;
}
}
// 공통 계산 로직을 한 곳에
function recalculateTotals(state: CartState): CartState {
const subtotal = state.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
const discount = subtotal >= 100000 ? subtotal * 0.1 : 0;
const shipping = subtotal >= 50000 ? 0 : 3000;
const total = subtotal - discount + shipping;
return { ...state, subtotal, discount, shipping, total };
}이제 컴포넌트에서는 단순히 액션을 보내기만 하면 됩니다.
function Cart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const handleQuantityChange = (itemId: string, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', itemId, quantity });
};
const handleRemove = (itemId: string) => {
dispatch({ type: 'REMOVE_ITEM', itemId });
};
// UI 렌더링...
}이 구조의 장점은 명확합니다. 하나의 액션이 관련된 모든 상태를 한 번에 업데이트하므로 중간 상태가 발생할 여지가 없고, 할인 정책이 바뀌면 recalculateTotals 함수 하나만 수정하면 됩니다. 또한 콘솔에 액션을 로깅하면 14:32:15 - UPDATE_QUANTITY처럼 변경 이력이 남아 디버깅이 쉬워집니다.
useState vs useReducer 선택 기준
모든 상태 관리에 useReducer를 쓸 필요는 없습니다. 오히려 단순한 상태에 useReducer를 쓰면 보일러플레이트만 늘어납니다.
| 기준 | useState | useReducer |
|---|---|---|
| 상태 개수 | 1-2개 독립적인 상태 | 여러 상태가 연관되어 변함 |
| 변경 복잡도 | 단순한 값 설정 | 복잡한 계산이나 조건 분기 |
| 테스트 | 컴포넌트 통합 테스트 | 리듀서 순수 함수 단위 테스트 |
| 디버깅 | 개별 상태 추적 | 액션 로깅으로 변경 이력 추적 |
| 로직 위치 | 컴포넌트 내부 | 컴포넌트 외부로 분리 가능 |
useState가 적합한 경우
독립적이고 단순한 상태는 useState가 좋습니다.
// 이런 경우는 useState가 적합
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState('home');토글, 입력 필드, 탭 선택처럼 상태 하나가 독립적으로 변하는 경우입니다. 이런 상태에 useReducer를 쓰면 코드만 복잡해집니다.
useReducer가 적합한 경우
다음 중 하나라도 해당되면 useReducer를 고려합니다.
- 여러 상태가 연관되어 함께 변할 때: 앞서 본 장바구니처럼, 하나의 사용자 행동이 여러 상태를 동시에 바꿔야 할 때입니다.
- 상태 변경 방법이 여러 가지일 때: 게임 상태를 예로 들면, "시작", "일시정지", "재개", "종료", "점수 획득", "레벨업" 등 다양한 액션이 있습니다.
- 이전 상태에 기반한 복잡한 계산이 필요할 때: 단순히 값을 설정하는 게 아니라, 이전 상태를 참조해서 새 상태를 계산해야 할 때입니다.
- 상태 로직을 컴포넌트 밖으로 분리하고 싶을 때: 리듀서 함수는 컴포넌트와 독립적이므로, 별도 파일로 분리하고 테스트를 작성하기 쉽습니다.
선택 기준 요약
"값을 X로 설정한다"로 충분하면 useState, "Y라는 이유로 Z를 했다"가 필요하면 useReducer입니다.
TypeScript로 타입 안전하게
TypeScript를 사용하면 잘못된 액션 타입이나 누락된 속성을 컴파일 타임에 잡을 수 있습니다.
// 상태 타입
interface FormState {
values: { email: string; password: string };
errors: { email?: string; password?: string };
isSubmitting: boolean;
}
// 액션 타입 - 유니온 타입으로 정의
type FormAction =
| { type: 'SET_FIELD'; field: keyof FormState['values']; value: string }
| { type: 'SET_ERROR'; field: keyof FormState['errors']; error: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_FAILURE'; error: string }
| { type: 'RESET' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined },
};
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false };
case 'SUBMIT_FAILURE':
return {
...state,
isSubmitting: false,
errors: { ...state.errors, email: action.error },
};
case 'RESET':
return initialFormState;
default:
// 모든 케이스를 처리했는지 컴파일러가 확인
const _exhaustiveCheck: never = action;
return state;
}
}마지막의 never 타입 트릭은 중요합니다. 새로운 액션 타입을 FormAction 유니온에 추가했는데 리듀서에서 처리하지 않으면 컴파일 에러가 발생합니다. 이렇게 하면 실수로 케이스를 빠뜨리는 것을 방지할 수 있습니다.
실전 패턴
실무에서 자주 사용하는 패턴들을 살펴보겠습니다.
지연 초기화
초기 상태를 계산하는 데 비용이 들 때 유용합니다. localStorage에서 데이터를 읽거나, 복잡한 초기값을 계산해야 할 때 사용합니다.
// 세 번째 인자로 초기화 함수를 전달
function init(defaultCount: number): CounterState {
const saved = localStorage.getItem('counter');
return { count: saved ? parseInt(saved, 10) : defaultCount };
}
function Counter({ startFrom = 0 }: { startFrom?: number }) {
// init 함수는 마운트 시 한 번만 실행됨
const [state, dispatch] = useReducer(counterReducer, startFrom, init);
// ...
}컴포넌트가 리렌더링될 때마다 localStorage를 읽지 않고, 처음 마운트될 때만 읽습니다.
액션 생성자 함수
액션 객체를 직접 만드는 대신 함수를 통해 생성하면 오타를 방지하고 자동완성의 도움을 받을 수 있습니다.
// 액션 생성자
const formActions = {
setField: (field: keyof FormState['values'], value: string) => ({
type: 'SET_FIELD' as const,
field,
value,
}),
submit: () => ({ type: 'SUBMIT_START' as const }),
submitSuccess: () => ({ type: 'SUBMIT_SUCCESS' as const }),
submitFailure: (error: string) => ({
type: 'SUBMIT_FAILURE' as const,
error,
}),
};
// 사용
dispatch(formActions.setField('email', 'user@example.com'));
dispatch(formActions.submit());as const를 사용하면 타입이 리터럴 타입으로 추론되어, TypeScript가 액션 타입을 정확하게 구분할 수 있습니다.
Immer로 불변성 관리 간소화
상태 객체가 깊게 중첩되어 있으면 불변성을 유지하면서 업데이트하는 코드가 복잡해집니다. use-immer 패키지의 useImmerReducer를 사용하면 마치 상태를 직접 수정하는 것처럼 코드를 작성할 수 있습니다.
import { useImmerReducer } from 'use-immer';
interface NestedState {
user: {
profile: {
name: string;
settings: {
theme: string;
notifications: boolean;
};
};
};
}
function settingsReducer(draft: NestedState, action: SettingsAction) {
switch (action.type) {
case 'UPDATE_THEME':
// 직접 수정하는 것처럼 작성 (Immer가 내부적으로 불변성 관리)
draft.user.profile.settings.theme = action.theme;
break;
case 'TOGGLE_NOTIFICATIONS':
draft.user.profile.settings.notifications =
!draft.user.profile.settings.notifications;
break;
}
}
function Settings() {
const [state, dispatch] = useImmerReducer(settingsReducer, initialState);
// ...
}Immer 사용 시점
Immer는 깊게 중첩된 객체를 다룰 때 효과적입니다. 얕은 객체라면 스프레드 연산자로 충분하며, 오히려 Immer의 오버헤드가 불필요할 수 있습니다.
Context와 결합하기
useReducer를 useContext와 함께 사용하면 props drilling 없이 여러 컴포넌트에서 상태를 공유할 수 있습니다. 소규모 앱에서는 Redux 같은 외부 라이브러리 없이도 충분한 상태 관리가 가능합니다.
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
type TodoAction =
| { type: 'ADD'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'DELETE'; id: number }
| { type: 'SET_FILTER'; filter: TodoState['filter'] };
// 1. Context 생성 - state와 dispatch를 분리
const TodoStateContext = createContext<TodoState | null>(null);
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
// 2. Provider 컴포넌트
function TodoProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
// 3. 커스텀 훅으로 사용성 개선
function useTodoState() {
const context = useContext(TodoStateContext);
if (!context) {
throw new Error('useTodoState는 TodoProvider 내부에서 사용해야 합니다');
}
return context;
}
function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (!context) {
throw new Error('useTodoDispatch는 TodoProvider 내부에서 사용해야 합니다');
}
return context;
}왜 state와 dispatch를 분리하는가?
위 예제에서 TodoStateContext와 TodoDispatchContext를 분리한 이유가 있습니다. 만약 하나의 Context에 { state, dispatch }를 함께 넣으면, state가 변경될 때마다 dispatch만 사용하는 컴포넌트도 리렌더링됩니다.
dispatch를 별도 Context로 분리하면, 액션을 보내기만 하는 컴포넌트는 state 변경에 영향받지 않습니다.
dispatch는 안정적인 참조
React 공식 문서에 따르면 "dispatch 함수는 안정적인 정체성(stable identity)을 가집니다." 즉, 컴포넌트가 리렌더링되어도 dispatch 함수의 참조는 변하지 않으므로, useCallback으로 감싸거나 useMemo의 의존성 배열에 포함할 필요가 없습니다.
// 4. 컴포넌트에서 사용
function TodoItem({ id }: { id: number }) {
const state = useTodoState();
const dispatch = useTodoDispatch();
const todo = state.todos.find(t => t.id === id);
if (!todo) return null;
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'TOGGLE', id })}
/>
{todo.text}
</li>
);
}Context + useReducer 조합은 중소규모 앱에서 효과적입니다. 다만 상태가 자주 바뀌고 많은 컴포넌트가 구독하는 경우, Context의 리렌더링 특성 때문에 성능 문제가 생길 수 있습니다. 그런 경우에는 상태를 여러 Context로 분리하거나, Zustand 같은 외부 라이브러리를 검토하는 것이 좋습니다.
흔한 실수와 안티패턴
useReducer를 사용할 때 주의해야 할 점들입니다.
reducer 내부에서 side effect 실행
리듀서는 순수 함수여야 합니다. API 호출, localStorage 접근, 타이머 설정 같은 사이드 이펙트를 리듀서 내부에서 실행하면 예측 불가능한 동작이 발생할 수 있습니다.
// ❌ 잘못된 예: reducer 내부에서 API 호출
function reducer(state: State, action: Action) {
switch (action.type) {
case 'SAVE':
saveToServer(state); // side effect!
return state;
}
}
// ✅ 올바른 예: side effect는 컴포넌트 또는 useEffect에서
function Component() {
const [state, dispatch] = useReducer(reducer, initialState);
const handleSave = async () => {
dispatch({ type: 'SAVE_START' });
try {
await saveToServer(state);
dispatch({ type: 'SAVE_SUCCESS' });
} catch (error) {
dispatch({ type: 'SAVE_FAILURE', error: error.message });
}
};
}상태 직접 변경 (mutation)
리듀서에서 이전 상태를 직접 변경하면 React가 변경을 감지하지 못해 리렌더링이 발생하지 않습니다.
// ❌ 잘못된 예: 직접 변경
case 'ADD_ITEM':
state.items.push(action.item); // mutation!
return state; // 같은 참조를 반환
// ✅ 올바른 예: 새 객체 반환
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.item] };불변성 위반 주의
배열의 push, pop, splice나 객체 속성 직접 할당은 불변성을 위반합니다. 항상 새로운 배열/객체를 생성하여 반환하세요. 깊은 중첩 구조에서 불변성 관리가 어렵다면 Immer 사용을 고려하세요.
action type 오타
문자열 리터럴로 액션 타입을 직접 작성하면 오타를 발견하기 어렵습니다.
// ❌ 오타 발견 어려움
dispatch({ type: 'UPDTE_QUANTITY' }); // 'UPDATE'가 아니라 'UPDTE'
// ✅ 상수 또는 액션 생성자 사용
const ActionTypes = {
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
REMOVE_ITEM: 'REMOVE_ITEM',
} as const;
dispatch({ type: ActionTypes.UPDATE_QUANTITY, itemId, quantity });
// 또는 TypeScript 유니온 타입으로 컴파일 타임에 검증핵심 정리
- useState: 독립적이고 단순한 상태에 적합 (토글, 입력값, 선택 상태)
- useReducer: 여러 상태가 연관되어 변하거나, 변경 로직이 복잡하거나, 로직을 테스트하고 싶을 때
- 선택 기준: "값을 설정한다"면 useState, "사건이 발생했다"면 useReducer
- dispatch는 안정적: useCallback 없이 Context로 전달하거나 의존성 배열에서 생략 가능