리액트 useReducer vs useState: 언제 쓰고 어떻게 마이그레이션할까
useState는 가장 단순한 상태 관리 훅이고, useReducer는 여러 상태가 서로 연관되어 한꺼번에 업데이트되거나, 업데이트 로직이 분기·규칙으로 복잡할 때 유리합니다. 이 글에서는 선택 기준과 마이그레이션 방법을 간단한 예제로 알아봅니다.
1) 언제 useState, 언제 useReducer?
- useState: 독립적이고 단순한 값, 이벤트 당 1~2개의 필드만 변경, 로직이 짧음.
- useReducer: 여러 필드가 동시에 바뀜, 유효성/분기/리셋 규칙이 많음, 액션 로그/테스트가 필요함.
- 성능은 도구 선택 자체보다 업데이트 설계의 영향이 큼. 복잡도 ↑ → reducer 고려.
2) 마이그레이션 기본 원칙
- 현재의
setState호출들을 “의미 있는 동사(액션)”로 이름 붙인다. (increment,changeField,reset등) - 초기 상태 객체를 만든다. (
initialState) - 액션을 해석하는
reducer(state, action)를 작성하고,useReducer(reducer, initialState)로 교체한다. - 기존 이벤트 핸들러에서
dispatch({ type: '...' , payload })호출로 바꾼다.
예제 1 — 카운터(단순 useState → 규칙 많은 useReducer)
처음엔 useState로 충분했지만, “스텝 변경, 최대값 제한, 리셋” 규칙이 늘어나면 useReducer가 더 깔끔해집니다.
✅ 최종 버전: useReducer로 리팩터링
import React, { useReducer } from 'react';
const initialState = { value: 0, step: 1, max: 10 };
function reducer(state, action) {
switch (action.type) {
case 'increment': {
const next = state.value + state.step;
return { ...state, value: Math.min(next, state.max) };
}
case 'decrement': {
const next = state.value - state.step;
return { ...state, value: Math.max(next, 0) };
}
case 'changeStep': {
const step = Number(action.payload) || 1;
return { ...state, step };
}
case 'reset':
return initialState;
default:
return state;
}
}
export default function CounterWithReducer() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:420}}>
<h3>CounterWithReducer (useReducer)</h3>
<p>값: <strong>{state.value}</strong> / 최대 {state.max}</p>
<div style={{display:'flex', gap:8, margin:'8px 0'}}>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
</div>
<label>스텝 변경</label>
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: 'changeStep', payload: e.target.value })}
style={{width:100, marginLeft:8}}
/>
</section>
);
}▼ 실행 화면 설명: +/− 버튼으로 값이 스텝 단위로 변하고, 최대값을 넘지 않습니다. 스텝을 바꾸면 즉시 반영되며, “리셋”으로 초기 상태로 돌아갑니다. 규칙이 늘어도 로직이 reducer 하나로 모여 관리하기 쉽습니다.

예제 2 — 다중 필드 폼: useReducer로 일관 업데이트 & 리셋
이름/이메일/비밀번호처럼 여러 필드를 다루고, 필드별 에러/리셋/제출 처리까지 필요할 때 useReducer가 편합니다.
import React, { useReducer } from 'react';
const initialForm = {
name: '',
email: '',
password: '',
errors: {},
submitted: false,
};
function validate({ name, email, password }) {
const errors = {};
if (!name.trim()) errors.name = '이름을 입력하세요';
if (!email.includes('@')) errors.email = '이메일 형식이 올바르지 않습니다';
if (password.length < 6) errors.password = '비밀번호는 6자 이상';
return errors;
}
function reducer(state, action) {
switch (action.type) {
case 'change': {
const { field, value } = action.payload;
return { ...state, [field]: value, submitted: false };
}
case 'reset':
return initialForm;
case 'submit': {
const errors = validate(state);
if (Object.keys(errors).length > 0) {
return { ...state, errors, submitted: false };
}
return { ...state, errors: {}, submitted: true };
}
default:
return state;
}
}
export default function SignupFormReducer() {
const [state, dispatch] = useReducer(reducer, initialForm);
const onChange = (field) => (e) =>
dispatch({ type: 'change', payload: { field, value: e.target.value } });
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:520}}>
<h3>SignupForm (useReducer)</h3>
<label>이름</label>
<input value={state.name} onChange={onChange('name')} style={{display:'block', width:'100%', marginBottom:4}} />
<p style={{color:'#b91c1c', minHeight:18}}>{state.errors.name || ''}</p>
<label>이메일</label>
<input value={state.email} onChange={onChange('email')} style={{display:'block', width:'100%', marginBottom:4}} />
<p style={{color:'#b91c1c', minHeight:18}}>{state.errors.email || ''}</p>
<label>비밀번호</label>
<input type="password" value={state.password} onChange={onChange('password')} style={{display:'block', width:'100%', marginBottom:4}} />
<p style={{color:'#b91c1c', minHeight:18}}>{state.errors.password || ''}</p>
<div style={{display:'flex', gap:8}}>
<button onClick={() => dispatch({ type: 'submit' })}>제출</button>
<button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
</div>
{state.submitted && (
<p style={{color:'#065f46', marginTop:8}}>제출 성공! 🎉</p>
)}
</section>
);
}▼ 실행 화면 설명: 제출 시 유효성 검사 결과가 각 필드 아래 표시됩니다. 값/에러/제출 상태가 한 리듀서로 관리되어, 리셋·제출 흐름을 일관되게 제어할 수 있습니다.

3) 체크리스트 — useReducer로 갈아탈 때
- 이벤트 하나가 여러 필드를 동시에 바꾸는가?
- 업데이트 규칙(분기/최소·최대/유효성)이 점점 늘고 있는가?
- “리셋” 같은 공통 액션이 자주 필요한가?
- 디버깅/테스트를 위해 액션 단위 로그/추적이 유용한가?
4) 자주 하는 실수 & 베스트 프랙티스
- 액션명은 동사형으로:
changeField,submit,reset등 의미가 분명하게. - 불변성 유지:
{...state}로 새 객체를 반환(직접 변이 금지). - 초기 상태 상수화:
initialState/initialForm를 모듈 상단에. - 로직 분리: 유효성/파생 계산은 별도 함수(예:
validate)로 분리해 테스트 용이. - 컨텍스트와 결합: 전역적 공유가 필요하면
useReducer + Context조합 고려.
참고 링크
함께 보면 좋은 게시글
- 리액트 state 관리: useState 기본 사용법과 동작 원리
- 리액트 useEffect 사용법: 의존성 배열과 생명주기 이해
- 리액트 props 사용법: 부모에서 자식으로 데이터 전달하기
- React 이벤트 처리 방법: onClick·onChange·onSubmit 예제
- 리액트 컴포넌트 반복: map과 key로 리스트 렌더링 최적화
이 글이 도움이 되셨다면 공유 부탁 드립니다.



