useReducer는 useState처럼 state를 보관하지만, state를 바꾸는 모든 방법을 하나의 함수인 리듀서로 옮기는 React 훅입니다. 컴포넌트가 { type: 'increment' } 같은 액션 객체로 dispatch를 호출하면, React는 현재 state와 그 액션을 리듀서에 넘기고, 리듀서는 다음 state를 반환합니다.
버튼들은 카운트가 어떻게 바뀌는지가 아니라 무슨 일이 일어났는지만 말합니다. 모든 계산은 reducer 안에 있습니다. { count: state.count * 2 }를 반환하는 case 'double'과 그것을 dispatch하는 버튼을 추가해 보면 새로운 종류의 업데이트가 어떻게 들어맞는지 알 수 있습니다.
문법
const [state, dispatch] = useReducer(reducer, initialArg, init); // init is optional
function reducer(state, action) {
// return the next state
}
reducer는(state, action) => nextState함수입니다. 컴포넌트 바깥에 정의하세요. 컴포넌트 스코프의 어떤 것도 필요하지 않습니다.initialArg는 시작 state이며, 첫 렌더링에서만 사용됩니다.init은 선택 사항입니다. 넘기면 시작 state가 대신init(initialArg)가 됩니다.state는 이번 렌더링의 현재 state입니다.dispatch(action)은 액션을 리듀서에 보내고 그 결과로 렌더링을 예약합니다. 안정적인 함수라서 렌더링마다 같은 함수이므로, 아래로 넘기거나 의존성으로 나열해도 안전합니다.
액션은 어떤 값이든 될 수 있지만, 관례상 무슨 일이 일어났는지 설명하는 type 문자열과 리듀서에 필요한 데이터를 담은 객체입니다: { type: 'added', text: 'Buy milk' }.
리듀서 작성하기
대부분의 리듀서는 action.type에 대한 switch이며, 이벤트 종류마다 case가 하나씩 있습니다. 각 case는 완전히 새로운 state를 반환하며, 이전 state를 절대 바꾸지 않습니다. default case는 오류를 던지므로, dispatch({ type: 'incremnet' }) 같은 오타는 아무 일도 하지 않는 대신 확실하게 실패합니다.
액션 이름은 염두에 둔 state 변경(setTodos)이 아니라 사용자가 한 일(added, toggled, deleted)을 따라 지으세요. 그러면 컴포넌트는 이벤트 목록처럼 읽히고, 각 이벤트가 무엇을 뜻하는지는 리듀서 한곳에서 결정됩니다.
리듀서는 순수해야 합니다
리듀서는 순수 함수입니다. 같은 state와 액션이 주어지면 같은 결과를 반환하고, 그 밖의 일은 아무것도 하지 않습니다. 즉 안에서 직접 변경, 요청, 타이머, Math.random()이나 Date.now()를 쓰면 안 됩니다. React는 이 성질에 의존합니다. 개발 환경의 StrictMode에서 React는 순수하지 않은 리듀서를 알아차리도록 돕기 위해 액션마다 리듀서를 두 번 호출하고 결과 하나를 유지합니다. 여기 미리보기는 프로덕션 빌드처럼 실행되므로 이 페이지의 예제는 한 번만 호출합니다.
가장 많이 어기는 규칙은 직접 변경 금지입니다. state.todos에 push하고 state를 반환하면 같은 객체를 반환하는 것이므로, React는 변화를 보지 못하고 렌더링을 건너뜁니다. 배열과 객체 업데이트에서 설명한 것과 같은 함정입니다. 전개, map, filter로 새 배열과 객체를 만드세요.
// Wrong: mutates and returns the same object
case 'added':
state.todos.push(action.todo);
return state;
// Right: returns a new object with a new array
case 'added':
return { ...state, todos: [...state.todos, action.todo] };
서버에 저장하기처럼 이벤트에 속하는 부수 효과는 dispatch 옆의 이벤트 핸들러에 둡니다. localStorage와 동기화하기처럼 state에 속하는 부수 효과는 이펙트에 둡니다.
useReducer로 만드는 할 일 목록
다음은 리듀서가 실제 일을 하는 예입니다. 할 일을 추가하고, 토글하고, 삭제합니다. 리듀서 맨 위의 console.log는 모든 액션을 출력하므로, 디버깅하면서 state가 어떻게 바뀌는지 지켜보기에 편리합니다. 아무것도 바꾸지 않기 때문에 리듀서에서 흔히 허용되는 유일한 부수 효과이며, 배포하기 전에는 빼세요.
할 일을 추가하고, 체크박스를 체크하고, 하나를 삭제한 다음 콘솔을 읽어 보세요. 모든 변경이 액션과 그 데이터를 나타내는 한 줄입니다. 입력창의 텍스트는 필드에만 속하고 다른 이벤트가 건드리지 않으므로 useState에 남아 있습니다. 한 컴포넌트에서 두 훅을 섞어 쓰는 것은 흔한 일입니다.
새 할 일의 id는 클릭 핸들러에서 만들어져 액션에 담겨 전달되므로, 리듀서는 그것을 복사하기만 합니다. 리듀서 안에서 id: nextId++라고 쓰면 순수하지 않게 됩니다. 개발 환경의 StrictMode에서 두 번째 호출이 id 하나를 건너뛰게 됩니다.
dispatch는 state를 바로 바꾸지 않습니다
dispatch는 useState의 setter처럼 동작합니다. 렌더링을 예약하고, 현재 핸들러의 state 변수는 이전 값을 유지합니다. 새 state를 바로 쓰려면 리듀서를 직접 호출해서 계산하세요.
몇 번 클릭해 보세요. 첫 번째 로그 줄은 항상 한 단계 뒤처져 있고, 두 번째 줄은 렌더링 후 버튼의 숫자와 일치합니다. 리듀서는 일반 함수이므로 직접 호출해도 안전하며, 이것도 리듀서를 순수하게 유지할 때 얻는 이점입니다.
useState와 useReducer
두 훅 모두 state를 저장하며, 한쪽으로 작성한 것은 다른 쪽으로도 작성할 수 있습니다. 차이는 업데이트 로직이 어디에 있는지입니다.
| useState | useReducer | |
|---|---|---|
| 업데이트 로직 | 각 이벤트 핸들러 안 | 하나의 리듀서 함수 안 |
| 적합한 경우 | 독립적인 값 몇 개 | 여러 이벤트가 함께 바꾸는 여러 필드 |
| 코드 양 | 간단한 state에서는 더 적음 | 처음엔 더 많지만, 이벤트가 늘수록 더 적음 |
| 테스트 | 컴포넌트를 통해 테스트 | 리듀서를 일반 함수로 테스트 |
| 디버깅 | 어느 핸들러가 설정했는지 찾기 | 각 액션을 한곳에서 로그로 남기기 |
같은 state가 여러 핸들러에서 업데이트되는 것을 발견했을 때, 이벤트 하나가 일관성을 유지해야 하는 여러 state를 바꿔야 할 때, 또는 핸들러가 대부분 다음 state에 관한 로직일 때 useReducer를 사용하세요. 토글이나 텍스트 필드에는 useState가 더 짧고 명확합니다. 나중에 바꾸는 것도 어렵지 않습니다. setter를 dispatch로 바꾸고 로직을 case로 옮기면 됩니다.
지연 초기화
초기 state를 useReducer(reducer, createInitialState('Ada'))처럼 함수 호출로 쓰면, React는 그 결과를 처음에만 사용하는데도 그 호출이 렌더링마다 실행됩니다. 초기 state를 만드는 비용이 크거나 prop에서 파생되어야 한다면 세 번째 인자로 init 함수를 넘기세요. React는 첫 렌더링에서 init(initialArg)를 한 번만 호출합니다.
입력창에 입력해 보세요. 컴포넌트는 키를 누를 때마다 렌더링되지만 createInitialState는 한 번만 로그를 남깁니다. 이제 호출을 useReducer(reducer, createInitialState('Ada'))로 바꾸고 다시 입력해 보세요. 함수가 렌더링마다 실행되고, React는 첫 번째 이후 매번 그 결과를 버립니다.
함수를 호출한 결과가 아니라 함수 자체를 넘기세요. useReducer(reducer, 'Ada', createInitialState)는 지연 초기화이고, useReducer(reducer, createInitialState('Ada'))는 그렇지 않습니다.
컨텍스트와 함께 쓰는 useReducer
깊은 트리에서 state가 필요할 때 리듀서는 컨텍스트와 잘 어울립니다. 맨 위에서 state와 dispatch를 컨텍스트에 넣으면, 아래의 어떤 컴포넌트든 모든 단계에 props를 넘기지 않고도 state를 읽거나 액션을 보낼 수 있습니다. dispatch는 절대 바뀌지 않으므로, 액션만 보내는 컴포넌트는 별도의 dispatch 컨텍스트를 읽어서 state가 바뀔 때 다시 렌더링되지 않게 할 수 있습니다.
import { createContext, useContext, useReducer } from 'react';
const TodosContext = createContext(null);
const TodosDispatchContext = createContext(null);
export function TodosProvider({ children }) {
const [todos, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext value={todos}>
<TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
</TodosContext>
);
}
function AddTodo() {
const dispatch = useContext(TodosDispatchContext);
return <button onClick={() => dispatch({ type: 'added', text: 'New' })}>Add</button>;
}
React 19에서는 컨텍스트 객체 자체가 Provider로 동작하며(<TodosContext value={...}>), <TodosContext.Provider>도 여전히 동작합니다. useContext 페이지에서 컨텍스트가 컴포넌트에 어떻게 도달하고 언제 다시 렌더링시키는지 다룹니다.
자주 묻는 질문
React에서 useReducer란 무엇인가요?
업데이트를 액션으로 기술하는 state를 위한 훅입니다. const [state, dispatch] = useReducer(reducer, initialState)를 호출한 다음, 이벤트 핸들러에서 dispatch({ type: 'added' })를 호출합니다. React는 현재 state와 액션을 reducer에 넘기고, 리듀서가 반환하는 값이 다음 state가 됩니다.
useState 대신 useReducer를 써야 할 때는 언제인가요?
여러 이벤트가 같은 state를 연관된 방식으로 업데이트할 때, 다음 state가 여러 필드에 동시에 의존할 때, 또는 업데이트 로직이 길어서 컴포넌트 밖의 테스트 가능한 함수 하나로 두고 싶을 때입니다. 독립적인 값 한두 개라면 useState가 더 간단합니다.
리듀서는 왜 순수해야 하나요?
React는 같은 액션에 대해 리듀서를 여러 번 호출할 수 있으며(개발 환경의 StrictMode는 일부러 그렇게 합니다) 매번 같은 결과를 기대합니다. 그래서 리듀서는 state를 직접 변경하거나, 데이터를 가져오거나, 타이머를 설정하거나, 무작위 값을 읽으면 안 됩니다. 그런 일은 이벤트 핸들러나 이펙트에서 하세요.
dispatch는 state를 즉시 업데이트하나요?
아니요. useState의 setter처럼 dispatch는 렌더링을 예약합니다. 현재 이벤트 핸들러 안에서 state는 여전히 이전 값이고, 새 state는 다음 렌더링에서 나타납니다.
useReducer의 세 번째 인자는 무엇인가요?
선택적인 init 함수입니다. 이것을 넘기면 React는 첫 렌더링에서만 초기 state를 init(initialArg)로 계산합니다. 초기 state를 만드는 비용이 크거나 prop에 의존할 때 유용합니다.