여러 상태 변경이 하나의 규칙으로 움직일 때 useReducer가 전환에 이름을 붙여 줘. 상태와 action의 계약을 타입으로 묶어 보자.
상태 변경을 reducer 한곳에 모아
useReducer(reducer, initialState)는 현재 상태와 dispatch를 돌려줘. 컴포넌트는 dispatch({ type: 'submit_started' })처럼 무슨 일이 일어났는지 알리고, reducer가 이전 상태와 action을 받아 다음 상태를 계산해.
여러 값이 함께 바뀔 때 유용해
서로 독립적인 값 몇 개라면 useState가 더 간단해. 하지만 제출 시작에는 pending과 error가 함께 바뀌고, 성공에는 result와 pending이 함께 바뀌는 식으로 전환 규칙이 생기면 reducer가 어긋난 조합을 막아 줘.
Action은 discriminated union으로 표현해
TypeScript에서 type 필드를 식별자로 사용하면 action마다 필요한 payload를 정확히 요구할 수 있어. Switch의 모든 경우를 처리했는지도 검사할 수 있으므로 새 action을 추가할 때 빠진 분기를 찾기 쉬워.
Reducer는 순수하게 유지해
Reducer 안에서 fetch, 타이머, 로그 같은 부수 효과를 실행하지 마. 같은 state와 action에는 항상 같은 다음 state를 돌려줘야 해. 비동기 작업은 이벤트 핸들러나 effect에서 실행하고 결과를 action으로 전달해.
Setter 호출을 나열하기보다 상태 전환에 이름을 붙이고 싶을 때 써. Reducer는 복잡성을 없애기보다 한곳에 모아 검사 가능하게 만들어.
Dispatch의 참조는 안정적이야
useState의 setter처럼 dispatch도 렌더링 사이에 같은 참조를 유지해. 이벤트 핸들러와 Context value에 전달하기 쉽고, action만 알면 어느 컴포넌트에서도 같은 전환 규칙을 사용할 수 있어.
초기 계산이 비싸면 initializer를 분리해
useReducer(reducer, initialArg, init)의 세 번째 인자에 초기화 함수를 넘기면 첫 상태를 한 번만 계산할 수 있어. Reset action에서도 같은 init을 호출하면 초기 상태 만드는 규칙이 중복되지 않아.
Todo 목록이라면 add, toggle, remove, set_filter, start_edit, commit_edit, cancel_edit처럼 사용자가 일으킨 사건을 action 이름으로 남길 수 있어. 이 이름이 상태 변경의 기록이자 테스트 단위가 돼.
Code
useReducer와 판별된 union으로 form 상태 전환 만들기·tsx
import { useReducer } from "react";
type State =
| { status: "idle" }
| { status: "submitting"; draft: { title: string } }
| { status: "succeeded"; result: { id: string } }
| { status: "failed"; error: string; draft: { title: string } };
type Action =
| { type: "submit_started"; draft: { title: string } }
| { type: "submit_succeeded"; result: { id: string } }
| { type: "submit_failed"; error: string }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "submit_started":
return { status: "submitting", draft: action.draft };
case "submit_succeeded":
return { status: "succeeded", result: action.result };
case "submit_failed":
// 제출 중 상태에 draft가 있으면 다시 사용해 (있으면).
if (state.status !== "submitting") return state;
return { status: "failed", error: action.error, draft: state.draft };
case "reset":
return { status: "idle" };
default: {
// Exhaustiveness 체크 — 새 action type 추가하고 여기서 핸들 안 하면
// 컴파일 오류.
const _exhaustive: never = action;
return state;
}
}
}
export function NewPostForm() {
const [state, dispatch] = useReducer(reducer, { status: "idle" });
// ... 핸들러들이 dispatch({ type: 'submit_started', draft }) 등 호출.
return null;
}
같은 counter에서 useReducer와 useState 비교하기·tsx
// useState가 이김 — 단일 값, 이름 붙은 전환 없음.
function CounterA() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((n) => n + 1)}>+</button>
<span>{count}</span>
<button onClick={() => setCount((n) => n - 1)}>-</button>
</>
);
}
// 상태 규칙이 있으면 useReducer가 더 잘 맞아 (0 미만 금지, jump가 리셋).
type CountState = { value: number };
type CountAction =
| { type: "inc" }
| { type: "dec" }
| { type: "jump"; to: number };
function CounterB() {
const [state, dispatch] = useReducer(
(s: CountState, a: CountAction): CountState => {
switch (a.type) {
case "inc": return { value: s.value + 1 };
case "dec": return { value: Math.max(0, s.value - 1) }; // floor
case "jump": return { value: a.to };
}
},
{ value: 0 }
);
return (
<>
<button onClick={() => dispatch({ type: "inc" })}>+</button>
<span>{state.value}</span>
<button onClick={() => dispatch({ type: "dec" })}>-</button>
<button onClick={() => dispatch({ type: "jump", to: 100 })}>jump</button>
</>
);
}
todos, filter, editingId를 각각 useState로 관리하는 목록을 먼저 만든 뒤 useReducer로 옮겨. add, toggle, remove, set_filter, start_edit, commit_edit, cancel_edit action을 discriminated union으로 선언해. 편집 완료와 취소가 관련 상태를 한 번에 올바르게 바꾸는지 확인하고, 같은 사용자 동작을 두 버전에서 실행해 결과가 같은지 비교해.
Hint
컴포넌트에서 발생할 수 있는 사건을 먼저 나열하고 reducer의 switch가 각 사건을 하나씩 처리하게 해. 빠진 action은 never 검사로 찾고 reducer 테스트에서도 확인해.
Progress
Progress is local-only — sign in to sync across devices.