useReducer — useState로 부족할 때의 선택지
상태 업데이트 로직이 복잡해질수록 useState만으로는 코드가 지저분해집니다. 이때 useReducer는 어떤 구조적 이점을 가져다줄까요?
개념 정의
useReducer는 Reducer 패턴 을 활용하여 복잡한 상태 로직을 관리하는 훅입니다. (state, action) => newState 형태의 순수 함수(reducer)에 상태 전이 로직을 집중시키고, dispatch로 액션을 발행하여 상태를 변경합니다.
왜 필요한가
useState로 여러 상태를 관리하다 보면, 상태 간의 의존성이 복잡해지고 업데이트 로직이 여기저기 흩어집니다.
// ❌ useState가 많아지면 관리가 어려움
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
function addItem(item) {
setItems([...items, item]);
setTotal(total + item.price);
if (items.length + 1 >= 3) setDiscount(10);
// 연관된 상태를 여러 곳에서 함께 업데이트...
}
}
내부 동작
기본 구조
// reducer 함수 — 컴포넌트 바깥에 정의
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const newItems = [...state.items, action.payload];
const newTotal = newItems.reduce((sum, item) => sum + item.price, 0);
return {
...state,
items: newItems,
total: newTotal,
discount: newItems.length >= 3 ? 10 : 0,
};
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(item => item.id !== action.payload);
const newTotal = newItems.reduce((sum, item) => sum + item.price, 0);
return {
...state,
items: newItems,
total: newTotal,
discount: newItems.length >= 3 ? 10 : 0,
};
}
case 'CLEAR_CART':
return { items: [], total: 0, discount: 0 };
default:
throw new Error(`알 수 없는 액션: ${action.type}`);
}
}
// 컴포넌트에서 사용
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
discount: 0,
});
return (
<div>
<button onClick={() => dispatch({
type: 'ADD_ITEM',
payload: { id: 1, name: '상품A', price: 10000 },
})}>
상품 추가
</button>
<p>총액: {state.total}원 (할인: {state.discount}%)</p>
</div>
);
}
dispatch의 안정성
dispatch 함수는 컴포넌트의 전체 생명주기 동안 **참조가 변하지 않습니다 **.
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
// dispatch는 안정적이므로 useCallback이 불필요
// 자식에게 전달해도 불필요한 리렌더링이 발생하지 않음
return (
<div>
<TodoList todos={state.todos} />
<AddTodoForm dispatch={dispatch} />
</div>
);
}
// React.memo와 함께 사용하면 효과적
const AddTodoForm = React.memo(function AddTodoForm({ dispatch }) {
// dispatch 참조가 변하지 않으므로 이 컴포넌트는 리렌더링 되지 않음
const handleSubmit = (text) => {
dispatch({ type: 'ADD_TODO', payload: text });
};
// ...
});
lazy initialization
초기 상태 계산이 비용이 큰 경우 세 번째 인자로 init 함수를 전달합니다.
function createInitialState(userId) {
// 비용이 큰 초기화 로직
return {
todos: loadTodosFromStorage(userId),
filter: 'all',
};
}
function TodoApp({ userId }) {
// init 함수는 첫 렌더링에서만 실행됨
const [state, dispatch] = useReducer(
todoReducer,
userId, // init 함수에 전달될 인자
createInitialState // init 함수
);
}
useState vs useReducer 판단 기준
| 기준 | useState | useReducer |
|---|---|---|
| 상태 개수 | 1~2개의 독립적 상태 | 여러 연관된 상태 |
| 업데이트 로직 | 단순 (직접 값 설정) | 복잡 (조건부, 다단계) |
| 테스트 | 컴포넌트 단위 테스트 | reducer 함수 단독 테스트 가능 |
| 디버깅 | console.log | action 로그로 상태 변화 추적 |
| 자식 전달 | setter를 useCallback으로 감싸야 함 | dispatch는 안정적 참조 |
// useState가 적합한 경우
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
// useReducer가 적합한 경우
// 로딩 → 성공/실패 → 재시도 같은 상태 머신
const [state, dispatch] = useReducer(fetchReducer, {
data: null,
loading: false,
error: null,
});
useReducer + Context 조합
간단한 전역 상태 관리에 매우 효과적입니다.
// Context 생성
const TodoContext = createContext(null);
const TodoDispatchContext = createContext(null);
// Provider 컴포넌트
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return (
<TodoContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoContext.Provider>
);
}
// 커스텀 훅으로 사용 편의성 제공
function useTodos() {
return useContext(TodoContext);
}
function useTodoDispatch() {
return useContext(TodoDispatchContext);
}
// 사용하는 컴포넌트
function TodoItem({ todo }) {
const dispatch = useTodoDispatch();
return (
<li>
{todo.text}
<button onClick={() => dispatch({ type: 'DELETE', payload: todo.id })}>
삭제
</button>
</li>
);
}
state와 dispatch를 ** 별도의 Context로 분리 **하면, dispatch만 사용하는 컴포넌트가 state 변경에 의해 불필요하게 리렌더링되는 것을 방지할 수 있습니다.
실전: 데이터 페칭 reducer
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function useDataFetch(url) {
const [state, dispatch] = useReducer(fetchReducer, {
data: null,
loading: true,
error: null,
});
useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'FETCH_START' });
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
Reducer 작성 팁
// 1. 항상 새 객체를 반환 (불변성)
// ❌
state.count = state.count + 1;
return state;
// ✅
return { ...state, count: state.count + 1 };
// 2. default에서 에러를 던져 실수를 잡기
default:
throw new Error(`알 수 없는 액션: ${action.type}`);
// 3. Immer로 불변성 관리 간소화
import { useImmerReducer } from 'use-immer';
function reducer(draft, action) {
switch (action.type) {
case 'ADD_ITEM':
draft.items.push(action.payload); // 직접 변경 가능
break;
}
}
주의할 점
reducer 안에서 비동기 작업 수행
reducer는 ** 순수 함수 **여야 합니다. API 호출, localStorage 접근 같은 부수효과를 reducer 안에 넣으면 예측 불가능한 동작이 발생합니다. 비동기 작업은 useEffect나 이벤트 핸들러에서 수행하고, 그 결과를 dispatch로 전달해야 합니다.
state를 직접 변경(mutate)
reducer에서 state.count = state.count + 1; return state;처럼 기존 state를 직접 변경하면, Object.is 비교에서 같은 참조로 판단되어 리렌더링이 발생하지 않습니다. 항상 ** 새 객체를 반환 **해야 합니다.
Context + useReducer에서 불필요한 리렌더링
state와 dispatch를 하나의 Context에 넣으면, state가 변경될 때 dispatch만 사용하는 컴포넌트도 리렌더링됩니다. state와 dispatch를 별도의 Context로 분리 하면 이 문제를 방지할 수 있습니다.
정리
| 항목 | 설명 |
|---|---|
| useReducer의 핵심 | 상태 전이 로직을 reducer 함수에 집중 |
| dispatch 안정성 | 참조가 변하지 않음 — useCallback 불필요 |
| reducer 규칙 | 순수 함수, 새 객체 반환, 부수효과 금지 |
| Context 조합 | state/dispatch 분리 Context로 간단한 전역 상태 관리 |
| useState vs useReducer | 독립적 상태 1~2개면 useState, 연관된 복잡한 상태면 useReducer |
| 테스트 | reducer는 순수 함수이므로 컴포넌트 없이 단독 테스트 가능 |
상태 업데이트 로직이 세 줄 이상 되기 시작하면 useReducer를 고려할 시점입니다. 로직이 한 곳에 모이면서 디버깅이 크게 편해집니다.