10편 회원가입 form 의 객체 useState 가 좋은 출발점이었다. 그런데 거기에 검증 에러·로딩·에러 메시지·진행 단계까지 추가되면? const [form, setForm] = useState({...}) 가 7~10 필드의 거대한 객체가 되고, setForm(prev => ({ ...prev, ... })) 로직이 컴포넌트 안에 흩어진다. useReducer 가 정리하는 자리.
이번 14편은 useState 와 useReducer 의 정확한 차이 + reducer 패턴 작성법 + 13편 Context 와의 결합 + "언제 갈아탈지" 4 신호.
1. useState → useReducer 한 줄 비교
// useState (10편 form)
const [form, setForm] = useState({ email: '', password: '' });
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
// useReducer
const [form, dispatch] = useReducer(formReducer, { email: '', password: '' });
const handleChange = (e) => {
dispatch({ type: 'FIELD_CHANGE', field: e.target.name, value: e.target.value });
};
function formReducer(state, action) {
switch (action.type) {
case 'FIELD_CHANGE':
return { ...state, [action.field]: action.value };
case 'RESET':
return { email: '', password: '' };
default:
return state;
}
}
useReducer 의 핵심 — state 변화 로직 (어떻게 바꿀지) 을 reducer 함수 한 곳에 모음. 컴포넌트는 "무엇을 했는지" (action) 만 dispatch 하면 됨.
2. reducer 함수의 규칙
reducer 는 단순 함수 — (현재 state, action) => 새 state. 다만 3가지 규칙 엄수.
규칙 — ① 순수 함수 (같은 입력 → 같은 출력, side effect 0). ② 새 state 객체 반환 (직접 수정 ❌). ③ default 케이스 필수 (action.type 이 매칭 안 되면 state 그대로). 이 셋이 Redux 의 원칙 그대로다.
action 객체의 표준 형태:
// 최소 형태
{ type: 'INCREMENT' }
// payload 추가
{ type: 'ADD_TODO', text: '우유 사기', id: crypto.randomUUID() }
// TypeScript discriminated union (실전 표준)
type Action =
| { type: 'INCREMENT' }
| { type: 'ADD_TODO'; text: string; id: string }
| { type: 'DELETE_TODO'; id: string };
TypeScript 의 discriminated union 으로 reducer 안 switch 의 각 case 에서 자동 타입 좁히기. action.text 가 ADD_TODO case 안에서만 string 으로 인식되어 IDE 가 잡아준다.
3. 언제 갈아탈까 — 4 신호
useState 가 충분한지 useReducer 가 필요한지 판단 기준. 하나라도 해당하면 useReducer 고려.
// 신호 1 — state 가 객체이고 필드 4개 이상
const [state, setState] = useState({
email: '', password: '', name: '', phone: '', address: '', terms: false
});
// 신호 2 — 한 핸들러가 여러 필드를 동시에 바꿈
setState(prev => ({
...prev,
email: validated,
error: null,
loading: true,
}));
// 신호 3 — 같은 state 변경 로직이 여러 핸들러에 반복
// (handleSubmit · handleReset · handleAutoFill 모두 비슷한 spread)
// 신호 4 — 같은 state 를 두 컴포넌트에서 변경
// (Context + dispatch 조합 자연스러움)
거꾸로 useState 가 답인 경우 — 단일 값(boolean·string·number), 변경 패턴이 단순, 컴포넌트 한 곳에서만 변경. 무리해서 reducer 쓰면 코드량만 늘어남.
4. Context + useReducer — Redux 직전 단계
13편의 Context 패턴 + useReducer 결합. 작은~중간 앱에선 Redux 도입 없이 충분.
// CartContext.tsx
type State = { items: CartItem[]; total: number };
type Action =
| { type: 'ADD'; item: CartItem }
| { type: 'REMOVE'; id: string }
| { type: 'CLEAR' };
function cartReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD':
return { items: [...state.items, action.item], total: state.total + action.item.price };
case 'REMOVE':
const removed = state.items.find(i => i.id === action.id);
return {
items: state.items.filter(i => i.id !== action.id),
total: state.total - (removed?.price || 0),
};
case 'CLEAR':
return { items: [], total: 0 };
}
}
const StateContext = createContext<State>(null!);
const DispatchContext = createContext<React.Dispatch<Action>>(null!);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
export const useCart = () => useContext(StateContext);
export const useCartDispatch = () => useContext(DispatchContext);
State 와 Dispatch 를 다른 Context 로 분리 (13편 성능 함정 해결). 카운트만 보는 컴포넌트는 useCart, add 만 하는 컴포넌트는 useCartDispatch. dispatch 는 안 바뀌니 재렌더 0.
여기서 Redux 가 필요해지는 지점 — middleware (logging·persistence·async), redux-devtools (time travel debugging), 큰 팀의 표준화. 그 미만이면 Context + useReducer 가 충분. 한 번 Redux 로 가면 boilerplate 비용을 영구히 짊어진다.
14편으로 state 관리의 큰 그림이 완성. 15편부터는 같은 React 앱 안에서 여러 페이지를 다루는 React Router — URL ↔ 컴포넌트 매핑.
다음 글
React 교재 15편 — React Router v7. SPA 라우팅, useNavigate, 동적 파라미터, nested routes.