useReducer 훅 소개
useReducer의 state·action·dispatch 흐름으로 여러 상태 전이를 한곳에 모으고 Context와 결합할 때의 책임 경계를 익힙니다.
React에서 여러 상태 갱신이 여러 이벤트 처리 함수에 흩어져 읽기 어려울 때, useReducer를 쓰면 상태를 바꾸는 규칙을 리듀서 함수 한곳으로 모을 수 있습니다. 4장에서 useReducer를 처음 다뤘다면, 이 글에서는 상태 관리의 관점에서 그 규칙과 Context와의 역할 분담을 정리합니다.
useReducer가 언제나 useState보다 나은 것은 아닙니다. 단순한 토글이나 입력값은 useState가 더 짧고 직접적이며, 리듀서는 “무슨 일이 있었는가”와 “그 일 뒤에 상태가 어떻게 바뀌는가”를 나눌 가치가 있을 때 유용합니다. Context와 함께 쓸 수도 있지만, Context 자체가 전역 상태 저장소가 되는 것은 아닙니다. state는 useReducer를 호출한 컴포넌트가 가지고, Context는 고른 Provider의 하위 트리에 state와 dispatch를 전달할 뿐입니다.
useReducer 상태 전이 계약 — 상태 추적
count 0 핸들러의 dispatch 뒤 지역 snapshot 0과 새 reducer state 1/다음 UI 1이 다르다. 기존 객체 불변과 updater 타이밍을 같은 역할로 보여 줄 가치가 있다.
REACT STATE · OWNERSHIP & TRANSITION
dispatch는 다음 렌더를 요청하고 현재 snapshot은 유지합니다
핸들러는 사건을 action으로 보냅니다. 순수 reducer가 현재 state와 action으로 다음 state를 계산하며, 컴포넌트가 그 state를 소유합니다.
실제 카운터 · action으로 설명하는 전이
count: 0
incremented → 다음 렌더 1
amountAdded, amount: 5 → 6
reset → 0dispatch 직후 실행 중인 핸들러에서 읽는 state는 이전 snapshot입니다. 알 수 없는 action은 본문의 reducer가 오류로 드러냅니다.
Action 계약
action은 보통 type과 계산에 필요한 최소 정보만 담아, 값 설정 명령보다 발생한 사건을 설명합니다.
한 상호작용이 여러 필드를 바꾸더라도 하나의 의미 있는 action으로 표현할 수 있습니다.
Snapshot과 bailout
dispatch 직후 같은 핸들러에서 읽는 state는 이전 snapshot입니다.
반환값이 현재 state와 Object.is로 같으면 React는 해당 컴포넌트와 자식의 렌더를 건너뜁니다.
-
UI 사건
클릭·입력·응답 도착처럼 상태가 바뀌어야 하는 사건이 발생합니다. 핸들러는 다음 state를 직접 저장하는 대신 action을 만듭니다.
-
dispatch(action)안정적인 identity를 가진
dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. -
순수 reducer
reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. -
다음 렌더
React는 reducer 반환값을 다음 state로 저장하고 그 snapshot으로 UI를 렌더합니다. 현재 핸들러의 지역 변수는 중간에 바뀌지 않습니다.
불변 갱신
객체는 spread, 배열은 map·filter 같은 방식으로 새 구조를 반환합니다. 의미 있는 변화가 없을 때는 기존 state를 그대로 반환할 수 있습니다.
부수효과 경계
API 요청·타이머·저장소 접근은 reducer 밖에 둡니다. 상호작용 때문에 생긴 작업은 이벤트 핸들러에서, 렌더 결과와 외부 시스템의 동기화는 Effect에서 처리합니다.
구현 전 체크리스트
- Owner와 scope: 어느 컴포넌트가 state를 소유하고 누가 읽어야 하는가?
- State shape와 init: 함께 변하는 필드와 불변 조건은 무엇이며 초기화 비용은 큰가?
- Action vocabulary: 각 상호작용과 최소 payload가 상태 변화의 이유를 드러내는가?
- Reducer 계약: 모든 분기가 순수하고 불변 갱신을 하며 알 수 없는 action을 분명히 처리하는가?
- 공급 경계: props로 충분한가, 아니면 제한된 Provider 하위 트리에 state와 dispatch를 공급해야 하는가?
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | action은 보통 type과 계산에 필요한 최소 정보만 담아, 값 설정 명령보다 발생한 사건을 설명합니다. |
| 원도표 설명 | 반환값이 현재 state와 Object.is로 같으면 React는 해당 컴포넌트와 자식의 렌더를 건너뜁니다. |
| 원도표 설명 | 안정적인 identity를 가진 dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. |
| 원도표 조건·주석 | dispatch(action) 안정적인 identity를 가진 dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. |
| 원도표 설명 | reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. |
| 원도표 조건·주석 | 순수 reducer reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. |
| 원도표 설명 | 객체는 spread, 배열은 map·filter 같은 방식으로 새 구조를 반환합니다. 의미 있는 변화가 없을 때는 기존 state를 그대로 반환할 수 있습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 handler | handler.count: 0 / action: incremented | count 0 snapshot에서 incremented를 dispatch한다 | dispatch는 현재 지역 변수를 즉시 바꾸지 않는다 |
| reducer 결과 | old: count0 / next: count1 | 기존 객체를 유지하며 count 1의 새 state를 만든다 | 순수 계산이 전이 결과를 정한다 |
| 다음 렌더 | UI: 1 / 기존 snapshot: 0 | UI는 count 1을 읽고 기존 handler의 관찰은 0이다 | 현재 실행의 snapshot과 다음 렌더의 입력을 분리한다 |
useReducer 상태 전이 계약 — 상태 추적
count 0 핸들러의 dispatch 뒤 지역 snapshot 0과 새 reducer state 1/다음 UI 1이 다르다. 기존 객체 불변과 updater 타이밍을 같은 역할로 보여 줄 가치가 있다.
REACT STATE · OWNERSHIP & TRANSITION
dispatch는 다음 렌더를 요청하고 현재 snapshot은 유지합니다
핸들러는 사건을 action으로 보냅니다. 순수 reducer가 현재 state와 action으로 다음 state를 계산하며, 컴포넌트가 그 state를 소유합니다.
실제 카운터 · action으로 설명하는 전이
count: 0
incremented → 다음 렌더 1
amountAdded, amount: 5 → 6
reset → 0dispatch 직후 실행 중인 핸들러에서 읽는 state는 이전 snapshot입니다. 알 수 없는 action은 본문의 reducer가 오류로 드러냅니다.
Action 계약
action은 보통 type과 계산에 필요한 최소 정보만 담아, 값 설정 명령보다 발생한 사건을 설명합니다.
한 상호작용이 여러 필드를 바꾸더라도 하나의 의미 있는 action으로 표현할 수 있습니다.
Snapshot과 bailout
dispatch 직후 같은 핸들러에서 읽는 state는 이전 snapshot입니다.
반환값이 현재 state와 Object.is로 같으면 React는 해당 컴포넌트와 자식의 렌더를 건너뜁니다.
-
UI 사건
클릭·입력·응답 도착처럼 상태가 바뀌어야 하는 사건이 발생합니다. 핸들러는 다음 state를 직접 저장하는 대신 action을 만듭니다.
-
dispatch(action)안정적인 identity를 가진
dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. -
순수 reducer
reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. -
다음 렌더
React는 reducer 반환값을 다음 state로 저장하고 그 snapshot으로 UI를 렌더합니다. 현재 핸들러의 지역 변수는 중간에 바뀌지 않습니다.
불변 갱신
객체는 spread, 배열은 map·filter 같은 방식으로 새 구조를 반환합니다. 의미 있는 변화가 없을 때는 기존 state를 그대로 반환할 수 있습니다.
부수효과 경계
API 요청·타이머·저장소 접근은 reducer 밖에 둡니다. 상호작용 때문에 생긴 작업은 이벤트 핸들러에서, 렌더 결과와 외부 시스템의 동기화는 Effect에서 처리합니다.
구현 전 체크리스트
- Owner와 scope: 어느 컴포넌트가 state를 소유하고 누가 읽어야 하는가?
- State shape와 init: 함께 변하는 필드와 불변 조건은 무엇이며 초기화 비용은 큰가?
- Action vocabulary: 각 상호작용과 최소 payload가 상태 변화의 이유를 드러내는가?
- Reducer 계약: 모든 분기가 순수하고 불변 갱신을 하며 알 수 없는 action을 분명히 처리하는가?
- 공급 경계: props로 충분한가, 아니면 제한된 Provider 하위 트리에 state와 dispatch를 공급해야 하는가?
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | action은 보통 type과 계산에 필요한 최소 정보만 담아, 값 설정 명령보다 발생한 사건을 설명합니다. |
| 원도표 설명 | 반환값이 현재 state와 Object.is로 같으면 React는 해당 컴포넌트와 자식의 렌더를 건너뜁니다. |
| 원도표 설명 | 안정적인 identity를 가진 dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. |
| 원도표 조건·주석 | dispatch(action) 안정적인 identity를 가진 dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다. |
| 원도표 설명 | reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. |
| 원도표 조건·주석 | 순수 reducer reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다. |
| 원도표 설명 | 객체는 spread, 배열은 map·filter 같은 방식으로 새 구조를 반환합니다. 의미 있는 변화가 없을 때는 기존 state를 그대로 반환할 수 있습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 handler | handler.count: 0 / action: incremented | count 0 snapshot에서 incremented를 dispatch한다 | dispatch는 현재 지역 변수를 즉시 바꾸지 않는다 |
| reducer 결과 | old: count0 / next: count1 | 기존 객체를 유지하며 count 1의 새 state를 만든다 | 순수 계산이 전이 결과를 정한다 |
| 다음 렌더 | UI: 1 / 기존 snapshot: 0 | UI는 count 1을 읽고 기존 handler의 관찰은 0이다 | 현재 실행의 snapshot과 다음 렌더의 입력을 분리한다 |
위 그림은 dispatch가 다음 렌더를 요청하고 현재의 스냅샷은 그대로 둔다는 점을 보여 줍니다. state를 가진 Counter 컴포넌트에서 클릭 같은 UI 사건이 일어나면 처리 함수가 incremented 액션을 dispatch로 보내 다음 갱신을 요청합니다. 순수한 리듀서가 현재 state와 액션으로 count가 1인 새 state를 계산하고, React가 그 값을 다음 렌더의 UI에 씁니다. 그림의 점선처럼, count가 0일 때 실행 중이던 처리 함수의 지역 변수는 dispatch를 부른 뒤에도 여전히 0이며, 기존 state 객체도 바뀌지 않습니다.
그림 아래 칸은 이 글의 카운터 예제로 이어집니다. count가 0일 때 incremented를 보내면 다음 렌더는 1, amountAdded에 amount: 5를 보내면 6, reset을 보내면 0이 되며, 알 수 없는 액션은 리듀서가 오류로 드러냅니다. 그림의 세부 칸들이 정리하듯 액션은 보통 type과 계산에 필요한 최소한의 정보만 담아 값을 설정하는 명령보다 일어난 사건을 설명하고, 리듀서의 반환값이 현재 state와 Object.is로 같으면 React는 그 컴포넌트와 자식의 렌더링을 건너뜁니다. 객체는 스프레드로, 배열은 map이나 filter로 새 구조를 만들어 돌려주고, API 요청과 타이머, 저장소 접근은 리듀서 밖의 이벤트 처리 함수나 Effect에 둡니다.
useReducer의 기본 규칙
useReducer는 리듀서와 초기 인자를 받고, 필요하면 세 번째 인자로 초기화 함수를 받습니다. 돌려주는 배열의 모양은 useState와 같아서, 첫 칸은 현재 state이고 둘째 칸은 state를 바꾸는 함수입니다. 다른 점은 둘째 칸이 다음 값을 직접 받는 setter가 아니라, 무슨 일이 일어났는지를 담은 액션을 받는 dispatch라는 것입니다.
const [state, dispatch] = useReducer(reducer, initialArg, init);reducer(state, action)은 현재 state와 액션을 받아 다음 state를 돌려주는 순수 함수입니다. initialArg는 초기 state를 계산할 때 쓰는 값으로, init이 없으면 이 값 자체가 초기 state가 됩니다. init(initialArg)는 선택 사항이며, 초기 계산의 비용이 클 때 초기 state를 지연해서 만듭니다. 돌려받은 state는 현재 렌더의 스냅샷이고, dispatch(action)은 다음 렌더에 반영할 상태 갱신을 요청하며 반환값은 없습니다.
액션은 어떤 타입이든 될 수 있지만, 보통 사건을 구별하는 type과 리듀서가 계산에 필요한 최소한의 정보를 담은 객체를 씁니다. 하나의 상호작용이 여러 필드를 함께 바꾼다면, 여러 개의 “값 설정” 액션보다 하나의 의미 있는 사건으로 나타내는 편이 상태가 바뀐 이유를 더 잘 드러냅니다.
리듀서가 지켜야 할 규칙
리듀서는 렌더하는 동안 실행되므로 순수해야 합니다. 같은 state와 액션에는 언제나 같은 다음 state를 돌려주고, state의 객체나 배열을 직접 바꾸지 않습니다. 실제로 바뀐 것이 있을 때 새 객체나 배열을 돌려주며, 의미 있는 변화가 없다면 기존 state를 그대로 돌려줄 수 있습니다. API 요청이나 타이머, 저장소 접근 같은 부수 효과는 실행하지 않고, 알 수 없는 액션은 현재 state로 조용히 넘기기보다 오류로 드러내는 방식을 고를 수 있습니다.
React는 리듀서의 반환값과 현재 state를 Object.is로 비교해, 둘이 같으면 그 컴포넌트와 자식의 렌더링을 건너뜁니다. 다만 React가 컴포넌트 함수를 먼저 호출한 뒤 결과를 버릴 수도 있으므로, 이 최적화에 기대어 렌더하는 동안 부수 효과를 써서는 안 됩니다. 또 개발 모드의 Strict Mode는 실수로 넣은 순수하지 않은 로직을 찾으려고 리듀서와 초기화 함수를 두 번 호출할 수 있는데, 결과는 한 번만 쓰이므로 순수한 함수라면 동작이 달라지지 않습니다.
카운터로 보는 action과 상태 전이
import { useReducer } from 'react';
const initialState = { count: 0 };
function counterReducer(state, action) {
switch (action.type) {
case 'incremented':
return { ...state, count: state.count + 1 };
case 'decremented':
return { ...state, count: state.count - 1 };
case 'reset':
return initialState;
case 'amountAdded':
return { ...state, count: state.count + action.amount };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
export default function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<section>
<h2>Reducer 카운터</h2>
<p aria-live="polite">현재 카운트: {state.count}</p>
<button onClick={() => dispatch({ type: 'incremented' })}>
증가
</button>
<button onClick={() => dispatch({ type: 'decremented' })}>
감소
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
초기화
</button>
<button
onClick={() => dispatch({ type: 'amountAdded', amount: 5 })}
>
5 추가
</button>
</section>
);
}버튼은 다음 count를 직접 정하지 않고, 일어난 사건을 액션으로 보냅니다. 리듀서는 현재 state와 액션만으로 다음 state를 계산하고, React는 그 반환값으로 다음 렌더를 합니다. 이 컴포넌트를 기존 앱의 App에서 가져와 렌더링하면 처음 문구는 “현재 카운트: 0”입니다. 증가, 5 추가, 감소를 차례로 한 번씩 누르고 렌더가 반영된 문구를 읽으면 1, 6, 5가 됩니다. 초기화는 별도의 새 사건이며 initialState의 0으로 돌아갑니다. 원래 리듀서의 감소에는 아래 한계를 검사하는 코드가 없으므로, 0에서 감소하면 -1이 됩니다.
dispatch를 호출해도 실행 중인 이벤트 처리 함수의 state 변수는 바로 바뀌지 않습니다.
function handleIncrement() {
dispatch({ type: 'incremented' });
console.log(state.count); // 이 렌더가 받은 이전 snapshot
}이 처리 함수의 로그는 dispatch 전에 그 렌더가 받은 스냅샷을 읽습니다. 예를 들어 0이 보이던 렌더에서 호출한 로그는 0이고, 그 뒤에 화면이 1을 보여 줄 수 있습니다. 서로 다른 시점의 값을 읽었을 뿐 리듀서가 실패했다는 뜻은 아닙니다(React 문서의 dispatch 함수).
dispatch의 식별자는 안정적이어서, 이 함수를 props로 넘겨도 함수의 식별자 자체가 매번 바뀌지는 않습니다. 다만 부모가 다시 렌더링되면 자식도 실행될 수 있으므로, dispatch를 넘기면 불필요한 렌더링이 없다고 단정할 수는 없습니다. 메모이제이션과 렌더링 건너뛰기는 별개의 문제입니다. 부수 효과도 리듀서 밖에 둡니다. 사용자의 상호작용 때문에 생긴 요청은 이벤트 처리 함수에서 처리하고, 렌더의 결과를 외부 시스템과 맞춰야 할 때는 Effect를 씁니다.
useState와 useReducer 선택
useState의 업데이터도 이전 state를 바탕으로 다음 값을 계산할 수 있으므로, 이전 값에 기댄다는 사실 하나만으로 리듀서가 필요한 것은 아닙니다. 몇 가지 신호를 함께 봅니다. 단순하고 독립적인 값의 짧은 갱신은 useState가 더 읽기 쉽습니다. 여러 이벤트 처리 함수에 비슷한 갱신 코드와 분기가 되풀이된다면 리듀서가 규칙을 모으는 데 도움이 되고, 하나의 사건이 관련된 필드 여러 개를 함께 바꾸거나 지켜야 할 불변 조건이 있다면 리듀서의 액션이 그 단위를 분명히 해 줍니다. 순수한 전이 함수를 UI와 떼어 테스트하거나 액션마다 변화의 이유를 추적해야 할 때도 리듀서가 알맞습니다. 다만 리듀서는 액션과 분기 코드를 더 써야 하므로, 단순한 상태까지 모두 옮길 필요는 없습니다.
한 컴포넌트 안에서 useState와 useReducer를 함께 써도 됩니다. 고르는 기준은 state의 개수나 객체인지가 아니라, 바꾸는 규칙이 얼마나 복잡하고 설명하기 쉬운지입니다.
REACT STATE · OWNERSHIP & TRANSITION
전이 규칙과 공유 범위를 별개의 문제로 결정합니다
단순한 테마 토글은 useState로 직접 설명할 수 있습니다. 여러 사건과 불변 조건은 useReducer로 모으고, 필요할 때만 Context로 전달 범위를 넓힙니다.
실제 CounterProvider · 세 역할을 구분
CounterProvider → state 소유
counterReducer → 다음 state 계산
CounterStateContext → state 전달
CounterDispatchContext
→ dispatch 전달custom Hook은 별도 상태 소유자가 아닙니다. dispatch가 안정적이어도 부모 렌더와 bailout은 별도의 경로입니다.
| 판단 신호 | useState |
useReducer |
|---|---|---|
| 전이 규칙 | 단순 토글·입력처럼 setter와 updater만으로 의도가 바로 읽힙니다. | 여러 이벤트 핸들러에 분기와 상태 갱신 규칙이 흩어져 있습니다. |
| 관련 값 | 값이 독립적이고 함께 지켜야 할 불변 조건이 적습니다. | 하나의 사건이 관련 필드 여러 개를 함께 바꾸거나 전이 조건이 얽힙니다. |
| 설명 방식 | “다음 값을 무엇으로 둘지”를 가까운 코드에서 설명하는 편이 짧습니다. | “무슨 일이 있었고 어떻게 바뀌는지”를 action과 reducer로 분리하면 선명합니다. |
| 검증 비용 | 컴포넌트 동작을 읽는 것만으로 변경 규칙을 쉽게 추적할 수 있습니다. | 순수 reducer를 따로 테스트하고 action 로그로 변화 이유를 추적할 가치가 큽니다. |
| 코드 비용 | 초기 보일러플레이트가 적습니다. | action과 reducer 코드가 늘어나는 대신 복잡한 갱신 규칙을 한곳에 모읍니다. |
전이 규칙
useState: 단순 토글·입력처럼 setter와 updater만으로 의도가 바로 읽힙니다.
useReducer: 여러 이벤트 핸들러에 분기와 상태 갱신 규칙이 흩어져 있습니다.
관련 값
useState: 값이 독립적이고 함께 지켜야 할 불변 조건이 적습니다.
useReducer: 하나의 사건이 관련 필드 여러 개를 함께 바꾸거나 전이 조건이 얽힙니다.
설명 방식
useState: “다음 값을 무엇으로 둘지”를 가까운 코드에서 설명하는 편이 짧습니다.
useReducer: “무슨 일이 있었고 어떻게 바뀌는지”를 action과 reducer로 분리하면 선명합니다.
검증 비용
useState: 컴포넌트 동작을 읽는 것만으로 변경 규칙을 쉽게 추적할 수 있습니다.
useReducer: 순수 reducer를 따로 테스트하고 action 로그로 변화 이유를 추적할 가치가 큽니다.
코드 비용
useState: 초기 보일러플레이트가 적습니다.
useReducer: action과 reducer 코드가 늘어나는 대신 복잡한 갱신 규칙을 한곳에 모읍니다.
Hook을 호출한 컴포넌트
useState나 useReducer를 직접 또는 custom Hook을 통해 호출한 컴포넌트가 state를 소유합니다. custom Hook은 별도 owner가 아닙니다.
다음 state 계산
현재 state와 action을 받아 순수하고 불변인 전이 규칙만 수행합니다. API·타이머·저장소 접근과 공급 범위는 책임 밖입니다.
값 공급과 범위
React 19에서는 Context 객체를 Provider로 렌더하고 value를 줍니다. 소비자는 가장 가까운 일치 Provider의 값을 읽습니다.
Context는 전역 상태나 owner가 아니라 선택한 하위 트리의 전달 채널입니다.
구현 전 체크리스트
- Owner와 scope: 어느 컴포넌트가 state를 소유하고 누가 읽어야 하는가?
- State shape와 init: 함께 변하는 필드와 불변 조건은 무엇이며 초기화 비용은 큰가?
- Action vocabulary: 각 상호작용과 최소 payload가 상태 변화의 이유를 드러내는가?
- Reducer 계약: 모든 분기가 순수하고 불변 갱신을 하며 알 수 없는 action을 분명히 처리하는가?
- 공급 경계: props로 충분한가, 아니면 제한된 Provider 하위 트리에 state와 dispatch를 공급해야 하는가?
dispatch의 identity는 안정적이지만, 이를 props로 전달하는 것만으로 자식 렌더가 사라지는 것은 아닙니다. 부모 렌더와 memoization bailout은 별도로 판단합니다.
위 그림은 바꾸는 규칙과 공유 범위를 서로 다른 문제로 정한다는 점을 보여 줍니다. 위쪽의 표는 useState와 useReducer를 고르는 다섯 가지 신호를 비교합니다. 전이의 규칙에서는 단순한 토글이나 입력처럼 setter와 업데이터만으로 의도가 바로 읽히면 useState가, 여러 처리 함수에 분기와 갱신 규칙이 흩어져 있으면 useReducer가 알맞습니다. 관련 값에서는 값이 독립적이고 함께 지킬 불변 조건이 적은지, 아니면 하나의 사건이 여러 필드를 함께 바꾸는지를 봅니다. 설명 방식과 검증 비용, 코드 비용에서는 useState가 다음 값을 가까운 코드에서 짧게 설명하고 준비 코드가 적은 반면, useReducer는 “무슨 일이 있었고 어떻게 바뀌는지”를 액션과 리듀서로 나눠 순수 함수로 따로 테스트하고 추적하기 좋은 대신 코드가 늘어납니다.
그림 아래의 세 칸은 역할을 나눕니다. state는 useState나 useReducer를 직접, 또는 커스텀 훅을 통해 호출한 컴포넌트가 가지며 커스텀 훅은 별도의 소유자가 아닙니다. 리듀서는 현재 state와 액션으로 순수하고 불변인 전이 규칙만 실행하고, API와 타이머, 저장소 접근과 공급 범위는 맡지 않습니다. Context는 React 19에서 Context 객체를 Provider로 렌더링해 value를 넘기며, 소비자는 가장 가까운 일치하는 Provider의 값을 읽는 선택한 하위 트리의 전달 통로일 뿐입니다. 그림 위쪽의 예처럼 이 글의 CounterProvider에서도 CounterProvider가 state를 가지고, counterReducer가 다음 state를 계산하며, CounterStateContext와 CounterDispatchContext가 각각 state와 dispatch를 전달합니다.
reducer와 Context의 역할 분리
리듀서는 상태를 저장하거나 공급할 범위를 정하지 않고, 현재 state와 액션을 받아 다음 state를 계산하는 규칙일 뿐입니다. state는 useReducer를 직접, 또는 커스텀 훅을 통해 호출한 컴포넌트가 가집니다. 커스텀 훅은 상태 로직을 감싸지만 별도의 소유자가 되지는 않습니다. 여러 하위 컴포넌트가 state와 dispatch를 읽어야 하고 props를 되풀이해 넘기게 된다면, 그 state를 가진 컴포넌트가 Context Provider의 역할까지 맡을 수 있습니다.
아래 예시는 앞 카운터의 리듀서와 초기 state를 별도의 모듈인 src/state/counterReducer.js로 옮겨 내보냈다고 가정합니다.
import { createContext, useReducer } from 'react';
import { counterReducer, initialState } from '../state/counterReducer.js';
export const CounterStateContext = createContext(null);
export const CounterDispatchContext = createContext(null);
export function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<CounterStateContext value={state}>
<CounterDispatchContext value={dispatch}>
{children}
</CounterDispatchContext>
</CounterStateContext>
);
}이 프로젝트의 React 19에서는 Context 객체를 바로 Provider로 렌더링하고 value를 넘깁니다. 소비자는 트리에서 가장 가까운 일치하는 Provider의 값을 읽습니다. state와 dispatch를 서로 다른 Context로 나눈 덕분에, dispatch만 쓰는 컴포넌트는 state가 바뀌어도 새 Context 값을 받지 않습니다.
Context는 어디까지 전달할지를 정하는 통로이고, 리듀서는 어떻게 바꿀지를 정하는 계산 규칙입니다. 이 예시에서는 useReducer를 호출한 CounterProvider가 state의 소유자이며, Context가 저절로 앱 전체의 전역 상태가 되는 것은 아닙니다. 소비하는 범위가 작거나 props가 구조를 더 분명하게 드러낸다면 Context 없이 넘기는 방식도 충분히 좋습니다.
정리
useReducer(reducer, initialArg, init)은[state, dispatch]를 돌려주며, 리듀서는 현재 state와 액션으로 다음 state를 계산하는 순수 함수입니다.- 액션은 값을 설정하는 명령보다 일어난 사건을 나타내며, 보통
type과 계산에 필요한 최소한의 정보를 담습니다. - 리듀서는 state를 직접 바꾸지 않고 새 값을 돌려주며, 부수 효과는 이벤트 처리 함수나 Effect에 둡니다.
dispatch는 다음 렌더를 요청할 뿐 실행 중인 처리 함수의 state를 바꾸지 않으며, 반환값이 현재 state와 같으면 React가 렌더링을 건너뜁니다.useState와useReducer는 바꾸는 규칙의 복잡도로 고르고, 한 컴포넌트에서 함께 써도 됩니다.- state는
useReducer를 호출한 컴포넌트가 가지고, Context는 그 state와dispatch를 고른 하위 트리에 전달할 뿐입니다.
연습 문제
1. 액션을 차례로 적용하기
초기 상태에서 amountAdded에 amount: 3, decremented, reset, decremented를 차례로 적용합니다. 각 단계의 count와, 처음 초기 객체의 count는 무엇일까요?
해설 보기
원래 규칙의 결과는 3, 2, 0, -1입니다. 증가와 감소, 추가는 스프레드로 새 객체를 돌려주므로 이전 객체의 count를 바꾸지 않습니다. 그래서 initialState.count는 계속 0이고, reset은 그 초기 객체를 그대로 돌려줍니다.
2. 0 아래로 내려가지 않게 하기
“count가 0보다 작아지지 않아야 한다”는 새 요구가 생겼습니다. 어느 분기에 어떤 조건을 더해야 할까요? 감소 버튼을 비활성화하는 것만으로 충분할까요?
해설 보기
decremented 분기에서 현재 count가 0 이하인지 먼저 검사해, 그렇다면 state를 그대로 돌려주는 규칙을 둘 수 있습니다. 바뀐 것이 없으므로 기존 state를 돌려주면 React가 렌더링도 건너뜁니다.
버튼의 비활성화는 UI의 안내일 뿐 전이 규칙 자체의 한계는 아닙니다. 다른 곳에서도 같은 액션을 보낼 수 있다면 리듀서에도 조건이 있어야 합니다. 이때 어떤 입력을 허용할지부터 정하고, 알 수 없는 액션에 오류를 던지는 규칙과 혼동하지 마세요.
다음 글 Redux 기초 개념 소개에서는 리듀서와 액션의 개념을 앱 전체로 넓힌 Redux와 Redux Toolkit의 기초를 살펴봅니다.