useReducer
상태 전이를 action과 reducer로 분리해 Todo 목록을 구현하고 useState보다 적합한 복잡도 기준을 판단합니다.
앞 글에서는 useContext로 컴포넌트 사이에서 데이터를 공유하는 방법을 배웠습니다. 이번에는 여러 액션과 상태 전이를 한곳에서 관리할 때 쓰는 useReducer 훅을 살펴봅니다. useState는 숫자나 문자열, 불리언 같은 단순한 값이나 독립된 객체와 배열을 다룰 때 직관적이고 편리합니다. 하지만 여러 하위 상태가 서로 얽혀 있거나 상태를 바꾸는 로직이 복잡해 setState를 여러 번 불러야 한다면, useState만으로는 코드가 길어지고 유지보수가 어려워집니다.
이럴 때 useReducer가 유용합니다. useReducer는 Redux 같은 상태 관리 라이브러리의 핵심인 리듀서(reducer) 패턴을 React 훅으로 가져온 것으로, 상태를 갱신하는 로직을 컴포넌트에서 떼어 내 더 예측하기 쉽고 테스트하기 쉬운 코드를 쓰게 해 줍니다.
dispatch는 사건을 보내고 reducer는 다음 state만 계산한다 — 상태 추적
Todo action이 현재 배열과 순수 reducer를 거쳐 새 state/render로 바뀐다. reducer 안의 효과와 commit 뒤 저장을 분리해 실제 action/값을 추적할 수 있다.
REDUCER: ACTION TO NEXT STATE
dispatch는 action을 보내고 reducer는 다음 상태를 계산합니다
TodoListWithReducer는 추가·토글·삭제를 action으로 표현합니다. reducer는 기존 state를 변이하지 않고 다음 값을 반환하며, 렌더가 새 화면을 계산합니다. 저장과 요청은 reducer 밖에서 수행합니다.
TOGGLE_TODO · 같은 id만 변경
action: {type: TOGGLE_TODO,
payload: {id: 2}}
전: id2.completed = false
후: id2.completed = truemap은 대상 객체만 새로 만들고 나머지 항목은 그대로 둡니다. dispatch 직후 현재 핸들러의 state가 즉시 새 값으로 바뀌지는 않습니다.
순수 계산의 경계
변경이 없으면 기존 state 참조를 반환할 수도 있습니다. Todo reducer의 알 수 없는 action은 throw하는 이 예제의 정책입니다. AuthContext의 localStorage 쓰기는 반례이며 Effect나 이벤트 경계로 옮겨야 합니다.
next state→render는 다음 화면 반영이며 자동 사용자 이벤트가 아닙니다. 세 번째 init 함수는 초기 상태 계산에 쓰이고, 이후 action은 reducer가 처리합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원도표의 TOGGLE_TODO payload.id=2, completed:false→true 사례를 선택합니다. 다른 Todo와 그 참조는 유지하고 입력 배열·id 2 객체를 직접 바꾸지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| action 의도 | 입력 id 2: completed:false / action: TOGGLE_TODO · payload.id=2 | id 2의 completed가 false인 현재 목록에 TOGGLE_TODO를 dispatch한다 | dispatch는 다음 계산을 요청한다 |
| 순수 전이 | 이전 id 2: completed:false · 입력 유지 / 다음 id 2: completed:true · 새 객체 | reducer는 id 2만 completed:true인 새 객체로 바꾼 새 배열을 반환한다 | map에서 일치하는 id만 복사하며 다른 Todo 참조는 유지한다. 저장과 요청은 reducer 밖에 둔다 |
| 화면과 외부 경계 | UI id 2: completed:true / 저장: commit 뒤 외부 동기화 | completed:true인 id 2를 commit하고 필요한 Effect에서 저장한다 | 전이 계산과 외부 저장의 시점을 분리한다 |
dispatch는 사건을 보내고 reducer는 다음 state만 계산한다 — 상태 추적
Todo action이 현재 배열과 순수 reducer를 거쳐 새 state/render로 바뀐다. reducer 안의 효과와 commit 뒤 저장을 분리해 실제 action/값을 추적할 수 있다.
REDUCER: ACTION TO NEXT STATE
dispatch는 action을 보내고 reducer는 다음 상태를 계산합니다
TodoListWithReducer는 추가·토글·삭제를 action으로 표현합니다. reducer는 기존 state를 변이하지 않고 다음 값을 반환하며, 렌더가 새 화면을 계산합니다. 저장과 요청은 reducer 밖에서 수행합니다.
TOGGLE_TODO · 같은 id만 변경
action: {type: TOGGLE_TODO,
payload: {id: 2}}
전: id2.completed = false
후: id2.completed = truemap은 대상 객체만 새로 만들고 나머지 항목은 그대로 둡니다. dispatch 직후 현재 핸들러의 state가 즉시 새 값으로 바뀌지는 않습니다.
순수 계산의 경계
변경이 없으면 기존 state 참조를 반환할 수도 있습니다. Todo reducer의 알 수 없는 action은 throw하는 이 예제의 정책입니다. AuthContext의 localStorage 쓰기는 반례이며 Effect나 이벤트 경계로 옮겨야 합니다.
next state→render는 다음 화면 반영이며 자동 사용자 이벤트가 아닙니다. 세 번째 init 함수는 초기 상태 계산에 쓰이고, 이후 action은 reducer가 처리합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원도표의 TOGGLE_TODO payload.id=2, completed:false→true 사례를 선택합니다. 다른 Todo와 그 참조는 유지하고 입력 배열·id 2 객체를 직접 바꾸지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| action 의도 | 입력 id 2: completed:false / action: TOGGLE_TODO · payload.id=2 | id 2의 completed가 false인 현재 목록에 TOGGLE_TODO를 dispatch한다 | dispatch는 다음 계산을 요청한다 |
| 순수 전이 | 이전 id 2: completed:false · 입력 유지 / 다음 id 2: completed:true · 새 객체 | reducer는 id 2만 completed:true인 새 객체로 바꾼 새 배열을 반환한다 | map에서 일치하는 id만 복사하며 다른 Todo 참조는 유지한다. 저장과 요청은 reducer 밖에 둔다 |
| 화면과 외부 경계 | UI id 2: completed:true / 저장: commit 뒤 외부 동기화 | completed:true인 id 2를 commit하고 필요한 Effect에서 저장한다 | 전이 계산과 외부 저장의 시점을 분리한다 |
위 그림은 dispatch가 액션을 보내고 리듀서는 다음 상태만 계산하는 순환을 보여 줍니다. 입력이나 클릭 같은 사용자 이벤트가 dispatch로 액션을 전달하면, 순수한 리듀서가 현재 state와 액션으로 다음 state를 계산해 새 배열이나 객체를 돌려주고, React가 렌더한 뒤 커밋해 화면에 반영합니다. 그림의 점선 영역이 순수한 계산의 범위이며, 로컬 스토리지에 저장하는 것 같은 외부와의 동기화는 그 바깥의 Effect에서 합니다. 다음 state에서 렌더로 이어지는 화살표는 다음 화면의 반영일 뿐, 사용자 이벤트가 저절로 일어난다는 뜻은 아닙니다.
그림 아래의 예는 이 글의 TodoListWithReducer에서 {type: TOGGLE_TODO, payload: {id: 2}} 액션이 id가 2인 할 일의 completed를 false에서 true로 바꾸는 모습입니다. map은 대상 객체만 새로 만들고 나머지 항목은 그대로 두며, dispatch를 호출한 직후 현재 처리 함수의 state가 바로 새 값으로 바뀌지는 않습니다. 그림의 설명처럼 바뀐 것이 없다면 리듀서가 기존 state의 참조를 그대로 돌려줄 수도 있고, Todo 리듀서가 알 수 없는 액션에서 오류를 던지는 것은 이 예제가 정한 방식입니다. 이 글 뒤쪽의 AuthContext처럼 리듀서 안에서 로컬 스토리지에 쓰는 코드는 이 규칙을 어긴 예이며, 그 작업은 Effect나 이벤트 처리 함수로 옮겨야 합니다.
useReducer의 기본 개념
useReducer는 세 가지 요소로 이루어집니다. 첫째는 reducer 함수로, (state, action) => newState 모양의 순수 함수(pure function)입니다. 현재 상태(state)와 일어난 action을 인자로 받아 그 action에 맞는 새 상태(newState)를 돌려줍니다. 리듀서 안에서는 state를 직접 바꿔서는 안 되며, 바뀐 것이 있을 때 새 상태를 돌려주고 바뀐 것이 없다면 기존 참조를 돌려줄 수 있습니다. 또 리듀서에는 부수 효과(side effect)가 있어서는 안 되며, 부수 효과는 useEffect에서 처리합니다.
둘째는 dispatch 함수로, useReducer가 돌려줍니다. dispatch(action)처럼 호출해 리듀서에 action을 전달하며, action은 보통 { type: 'ACTION_TYPE', payload: 'data' } 같은 객체입니다. 셋째는 useReducer에 넘기는 초기 상태(initialState)로, useState의 초기값과 비슷합니다.
import React, { useReducer } from 'react';
// 1. reducer 함수 정의: (state, action) => newState
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
default:
throw new Error(); // 알 수 없는 액션 타입일 경우 에러 발생
}
}
function CounterWithReducer() {
// 2. useReducer 훅 사용: [현재 상태, dispatch 함수] = useReducer(reducer 함수, 초기 상태);
const [state, dispatch] = useReducer(reducer, { count: 0 }); // 초기 상태는 객체 형태
return (
<div style={{ border: '1px solid #7B1FA2', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>useReducer 카운터</h2>
<p>카운트: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })} style={{ marginLeft: '10px' }}>감소</button>
<button onClick={() => dispatch({ type: 'RESET' })} style={{ marginLeft: '10px' }}>리셋</button>
</div>
);
}
export default CounterWithReducer;reducer 함수는 state와 action을 받아 새 state를 돌려주며, switch 문으로 action.type에 따라 다른 로직을 실행합니다. 버튼에서 dispatch({ type: 'INCREMENT' })처럼 dispatch를 호출하면 리듀서에 INCREMENT 액션이 전달되고, 화면에서는 state.count로 현재 카운트를 읽습니다. 처음에는 “카운트: 0”이 보이고, 증가를 두 번 누른 뒤 감소를 한 번 누르면 “카운트: 1”, 리셋을 누르면 다시 “카운트: 0”이 됩니다.
더 깊이: 리듀서가 렌더하는 동안 실행된다는 것
리듀서라는 이름은 코드의 양을 줄인다(reduce)는 뜻이 아니라 배열의 reduce()에서 왔습니다. reduce()에 넘기는 함수가 지금까지의 결과와 현재 항목을 받아 다음 결과를 돌려주듯, React의 리듀서는 지금까지의 state와 액션을 받아 다음 state를 돌려줍니다. 그래서 액션의 배열이 있다면 actions.reduce(todoReducer, [])처럼 같은 리듀서로 최종 상태를 계산할 수도 있으며, React 문서는 React가 하는 일도 이와 비슷하다고 설명합니다(React 문서의 리듀서로 state 로직 추출하기).
이 관점은 리듀서가 순수해야 하는 이유도 보여 줍니다. dispatch로 보낸 액션은 다음 렌더까지 큐에 쌓였다가, React가 다음 렌더를 계산하는 동안 리듀서에 차례로 넘겨집니다. 즉 리듀서는 업데이터처럼 렌더 도중에 실행되므로, 그 안에서 요청을 보내거나 타이머를 걸면 개발 모드의 Strict Mode가 리듀서를 두 번 호출할 때 부수 효과도 함께 반복됩니다. 같은 문서는 액션 하나가 사용자의 상호작용 하나를 나타내도록, 예를 들어 다섯 필드를 지우는 초기화 버튼은 set_field 다섯 번이 아니라 reset_form 한 번으로 보내라고 권합니다. 그래야 리듀서에 남긴 로그만으로 어떤 상호작용이 어떤 순서로 일어났는지 다시 따라갈 수 있습니다.
useState vs useReducer
| 특징 | useState | useReducer |
|---|---|---|
| 복잡성 | 단순한 값이나 서로 독립된 여러 값 | 복잡한 상태 로직, 여러 하위 상태가 얽힌 경우 |
| 가독성 | 간단한 상태 변경에서 직관적 | 상태 변경 로직이 분리되어 예측하기 쉽고 테스트하기 좋음 |
| 업데이트 | setSomething(newValue) | dispatch({ type: 'ACTION' }) |
| 데이터 타입 | 숫자, 문자열, 불리언, 객체, 배열 등 모든 타입 | 보통 여러 상태를 담은 객체나 배열 |
| 코드 분리 | 상태 로직이 컴포넌트 안에 있음 | 상태 로직(reducer)을 컴포넌트 밖으로 뺄 수 있음 |
| 장점 | 간단하고 배우기 쉬움 | 상태 로직을 예측하기 쉽고 한곳에 모이며 디버깅하기 좋음 |
| 단점 | 상태 변경이 복잡해지면 코드가 어수선해짐 | 간단한 상태에도 반복되는 준비 코드가 늘어남 |
useReducer는 몇 가지 경우에 특히 잘 맞습니다. 로딩, 성공, 실패처럼 현재 상태에 따라 다음 상태가 여러 갈래로 정해지는 복잡한 상태 전이가 있을 때, 폼의 여러 필드 값이나 장바구니 항목의 수량과 총액처럼 여러 하위 상태가 서로 얽혀 함께 바뀌어야 할 때 씁니다. 리듀서를 별도의 파일로 빼서 재사용하거나 테스트하고 싶을 때도 좋습니다. 또 dispatch 함수는 useCallback으로 감싸지 않아도 언제나 같은 참조를 유지하므로, 여러 컴포넌트에 dispatch를 넘겨야 할 때 Context와 함께 쓰기 좋습니다.
useReducer를 활용한 투두(Todo) 리스트
3장의 실습에서 만든 Todo 앱의 상태 관리 로직을 useReducer로 바꿔, 복잡한 상태를 관리할 때의 이점을 느껴 봅니다. 바꾸는 범위는 상태 로직뿐입니다. 3장의 TodoList에서 이벤트 처리 함수마다 흩어져 있던 추가, 완료 전환, 삭제 계산을 리듀서 함수 하나로 모으고, 컴포넌트는 dispatch로 무슨 일이 일어났는지만 알립니다. 화면과 동작은 3장과 같으므로, 두 버전을 나란히 놓고 상태를 바꾸는 코드가 어디로 옮겨 갔는지 비교하면 됩니다.
todoReducer.js 생성 (리듀서 로직 분리)
src 폴더 안에 reducers 폴더를 만들고, 그 안에 다음 리듀서를 저장합니다. 이 파일에는 JSX가 없으므로 .js 확장자를 씁니다.
// 이 파일은 src 폴더 안에 reducers 폴더를 생성하고 그 안에 저장하는 것을 권장합니다.
// 액션 타입 상수 정의 (오타 방지 및 가독성 향상)
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const DELETE_TODO = 'DELETE_TODO';
// (1) reducer 함수 정의: state와 action을 받아 새로운 state를 반환
function todoReducer(state, action) {
switch (action.type) {
case ADD_TODO: {
// 새 할 일 ID 생성 (기존 최대 ID + 1)
// case 안에서 const를 선언하므로 중괄호로 범위를 나눕니다.
const newId = state.length > 0 ? Math.max(...state.map(item => item.id)) + 1 : 1;
return [
...state, // 기존 할 일 목록 복사
{
id: newId,
text: action.payload.text, // payload에서 텍스트 가져옴
completed: false,
},
];
}
case TOGGLE_TODO:
return state.map((todo) =>
todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
);
case DELETE_TODO:
return state.filter((todo) => todo.id !== action.payload.id);
default:
// 알 수 없는 액션 타입을 발견하려고 이 예제에서는 에러를 발생시킵니다.
throw new Error(`Unhandled action type: ${action.type}`);
}
}
export default todoReducer;todoReducer를 별도의 파일로 빼서 TodoList 컴포넌트와 상태 로직을 나눴습니다. 각 case는 action.type에 따라 다른 갱신 로직을 정의하고, 필요한 데이터는 action.payload로 받으며, 모든 갱신은 불변성을 지키도록 새 배열이나 객체를 돌려줍니다.
다음 ID도 상태에서 계산됩니다. 예를 들어 ID 3과 8인 두 할 일이 있을 때 ID 8을 지우고 새 할 일을 추가하면, 이 리듀서는 남은 가장 큰 ID인 3에 1을 더해 4를 붙입니다. 지워진 가장 큰 ID를 기억하는 전역 번호가 아니라, 이전 배열을 바꾸지 않으면서 현재 목록 안에서 고유한 값을 계산하는 규칙입니다.
TodoListWithReducer.jsx 컴포넌트
처음 실습할 때 로컬 스토리지의 todoItems_reducer 값은 없거나, 이 코드가 저장한 올바른 할 일 배열이어야 합니다. JSON을 파싱할 수 있다고 배열의 모양까지 보장되지는 않으므로, 이전 실험에서 null이나 객체를 저장했다면 개발자 도구에서 이 키만 지우고 다시 시작하세요. 다른 저장값까지 모두 지울 필요는 없습니다.
import React, { useState, useReducer, useEffect } from 'react';
import todoReducer, { ADD_TODO, TOGGLE_TODO, DELETE_TODO } from '../reducers/todoReducer'; // 리듀서와 액션 타입 불러오기
// 초기 상태를 설정하는 함수 (지연 초기화와 로컬 스토리지 결합)
const init = (initialTodos) => {
try {
const savedTodos = localStorage.getItem('todoItems_reducer');
return savedTodos ? JSON.parse(savedTodos) : initialTodos;
} catch (error) {
console.error("Failed to parse todos from localStorage", error);
return initialTodos;
}
};
function TodoListWithReducer() {
// 1. useReducer 훅 사용: [현재 상태, dispatch 함수] = useReducer(reducer 함수, 초기 상태, 초기화 함수);
// 두 번째 인자는 초기 상태, 세 번째 인자는 초기화 함수 (지연 초기화)
const [todos, dispatch] = useReducer(todoReducer, [], init); // 빈 배열은 init 함수의 initialTodos로 전달
// 새 할 일 입력 필드를 위한 useState (이것은 단순 상태이므로 useState 유지)
const [newTodoText, setNewTodoText] = useState('');
// 2. todos 상태가 변경될 때마다 로컬 스토리지에 저장 (useEffect 사용)
useEffect(() => {
localStorage.setItem('todoItems_reducer', JSON.stringify(todos));
}, [todos]); // todos가 의존성 배열에 있으므로, 변경될 때마다 실행
// 3. 새 할 일 입력 필드 변경 핸들러
const handleInputChange = (e) => {
setNewTodoText(e.target.value);
};
// 4. 할 일 추가 핸들러
const handleAddTodo = (e) => {
e.preventDefault();
if (newTodoText.trim() === '') return;
// dispatch 함수를 호출하여 ADD_TODO 액션 전달
dispatch({ type: ADD_TODO, payload: { text: newTodoText } });
setNewTodoText(''); // 입력 필드 초기화
};
// 5. 할 일 완료 상태 토글 핸들러
const handleToggleComplete = (id) => {
dispatch({ type: TOGGLE_TODO, payload: { id: id } });
};
// 6. 할 일 삭제 핸들러
const handleDeleteTodo = (id) => {
dispatch({ type: DELETE_TODO, payload: { id: id } });
};
// 7. JSX 렌더링
return (
<div style={{ maxWidth: '500px', margin: '30px auto', padding: '20px', border: '1px solid #7B1FA2', borderRadius: '10px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
<h2>Reducer로 관리하는 Todo List</h2>
{/* 할 일 추가 폼 */}
<form onSubmit={handleAddTodo} style={{ display: 'flex', marginBottom: '20px' }}>
<input
type="text"
value={newTodoText}
onChange={handleInputChange}
placeholder="새 할 일을 입력하세요..."
style={{ flexGrow: 1, padding: '10px', fontSize: '16px', border: '1px solid #ddd', borderRadius: '5px 0 0 5px' }}
/>
<button type="submit" style={{ padding: '10px 15px', fontSize: '16px', backgroundColor: '#7B1FA2', color: 'white', border: 'none', borderRadius: '0 5px 5px 0', cursor: 'pointer' }}>
추가
</button>
</form>
{/* 할 일 목록 */}
{todos.length === 0 ? (
<p style={{ textAlign: 'center', color: '#666' }}>아직 할 일이 없습니다. 새 할 일을 추가해 보세요!</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px',
borderBottom: '1px dashed #eee',
backgroundColor: todo.completed ? '#f0f4f8' : 'white', // 조건부 스타일링
}}
>
<div style={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggleComplete(todo.id)}
style={{ marginRight: '10px', transform: 'scale(1.2)' }}
/>
<span
style={{
fontSize: '18px',
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
</div>
<button
onClick={() => handleDeleteTodo(todo.id)}
style={{
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
padding: '5px 10px',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '14px',
}}
>
삭제
</button>
</li>
))}
</ul>
)}
</div>
);
}
export default TodoListWithReducer;useReducer(todoReducer, [], init)의 두 번째 인자 []는 init 함수가 받는 initialTodos가 되고, init이 돌려준 값이 첫 todos가 됩니다. 이벤트 처리 함수는 이제 새 배열을 직접 계산하지 않고 { type, payload } 모양의 액션 객체만 dispatch에 넘기므로, 3장의 처리 함수에 있던 map과 filter 계산은 리듀서로 옮겨 갔습니다. 컴포넌트에 남은 todos.map은 목록을 그리는 데만 씁니다.
App.jsx에서 사용
import React from 'react';
import './App.css';
import TodoListWithReducer from './components/TodoListWithReducer'; // useReducer 버전 TodoList 불러오기
function App() {
return (
<div className="App">
<h1>Reducer를 사용한 Todo List</h1>
<TodoListWithReducer />
</div>
);
}
export default App;화면은 3장의 Todo 앱과 같은 방식으로 동작하지만, 할 일을 추가하고 완료 상태를 바꾸고 삭제하는 로직은 모두 todoReducer 안에 있고, 컴포넌트는 dispatch로 어떤 일이 일어났는지만 알립니다. 입력 중인 글자처럼 단순한 상태는 그대로 useState로 둡니다.
useReducer의 초기화 함수
useState처럼 useReducer도 초기 상태를 지연 초기화할 수 있어, 초기 상태를 계산하는 비용이 클 때 유용합니다. useReducer의 세 번째 인자로 초기화 함수를 넘기면 됩니다.
const [state, dispatch] = useReducer(reducer, initialArg, init);init 함수는 initialArg를 인자로 받아 실제 초기 상태를 돌려줍니다. 초기화할 때만 쓰이며, 개발 모드의 Strict Mode에서는 순수성을 점검하려고 두 번 호출될 수 있습니다. 앞의 Todo 예제는 이 init 함수로 로컬 스토리지에서 할 일 목록을 불러오는 로직을 지연 초기화로 구현했습니다.
useReducer와 useContext 함께 사용하기
useReducer로 관리하는 상태와 dispatch 함수를 useContext로 하위 컴포넌트에 넘기면, 전역의 복잡한 상태를 효율적으로 관리할 수 있습니다. Redux 같은 전역 상태 관리 라이브러리의 내부 동작과 비슷한 방식입니다.
다음은 AuthContext와 useReducer를 결합한 예입니다. 결합하는 구조를 보여 주지만, authReducer 안에서 로컬 스토리지를 바꾸므로 앞에서 말한 순수성 원칙을 어기는 반례이기도 합니다. 저장과 삭제는 상태가 바뀐 뒤의 Effect나 이벤트 처리 함수로 옮기고, 리듀서는 다음 state만 돌려줘야 합니다. 또 하드코딩한 토큰과 사용자는 화면의 상태를 보여 주기 위한 값일 뿐, 실제 인증을 구현하지 않습니다. 이 파일은 JSX를 쓰므로 .jsx로 저장합니다.
import React, { createContext, useReducer, useContext, useEffect } from 'react';
// 1. AuthContext 생성
const AuthContext = createContext(null);
// 2. reducer 함수 정의
const authReducer = (state, action) => {
switch (action.type) {
case 'LOGIN':
localStorage.setItem('isLoggedIn', 'true'); // 로그인 시 로컬 스토리지 업데이트
localStorage.setItem('userToken', action.payload.token);
return { ...state, isLoggedIn: true, user: action.payload.user, token: action.payload.token };
case 'LOGOUT':
localStorage.removeItem('isLoggedIn'); // 로그아웃 시 로컬 스토리지 제거
localStorage.removeItem('userToken');
return { ...state, isLoggedIn: false, user: null, token: null };
case 'SET_INITIAL_STATE': // 초기 로드 액션
return action.payload;
default:
return state;
}
};
// 3. 초기 상태를 위한 초기화 함수
const initAuth = (initialState) => {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
const userToken = localStorage.getItem('userToken');
// 실제 앱에서는 토큰으로 사용자 정보를 다시 불러와야 할 수 있습니다.
const user = isLoggedIn ? { name: 'Logged In User' } : null; // 예시
return { ...initialState, isLoggedIn, user, token: userToken };
};
// 4. AuthProvider 컴포넌트 생성
export const AuthProvider = ({ children }) => {
const [authState, dispatch] = useReducer(authReducer, { isLoggedIn: false, user: null, token: null }, initAuth);
return (
<AuthContext.Provider value={{ authState, dispatch }}>
{children}
</AuthContext.Provider>
);
};
// 5. useContext 훅을 사용하기 위한 커스텀 훅 (선택 사항이지만 유용)
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};import React from 'react';
import './App.css';
import { AuthProvider, useAuth } from './contexts/AuthContext'; // AuthProvider와 useAuth 훅 불러오기
// 로그인/로그아웃 버튼을 표시하는 컴포넌트
function AuthButtons() {
const { authState, dispatch } = useAuth(); // useAuth 훅 사용
return (
<div style={{ margin: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', textAlign: 'center' }}>
{authState.isLoggedIn ? (
<>
<p>환영합니다, {authState.user.name}님!</p>
<button onClick={() => dispatch({ type: 'LOGOUT' })} style={{ padding: '10px 20px', backgroundColor: '#f44336', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
로그아웃
</button>
</>
) : (
<>
<p>로그인이 필요합니다.</p>
<button onClick={() => dispatch({ type: 'LOGIN', payload: { user: { name: '테스트유저' }, token: 'abc123def456' } })} style={{ padding: '10px 20px', backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
로그인
</button>
</>
)}
</div>
);
}
function App() {
return (
<div className="App">
<h1>useReducer + useContext 예시</h1>
<AuthProvider> {/* AuthProvider로 감싸서 모든 하위 컴포넌트에 인증 상태 제공 */}
<AuthButtons />
{/* 다른 컴포넌트들도 useAuth를 사용하여 인증 상태에 접근 가능 */}
</AuthProvider>
</div>
);
}
export default App;AuthProvider는 useReducer로 authState와 dispatch를 관리하고, 이 둘을 AuthContext.Provider의 value로 넘깁니다. 그러면 useAuth 커스텀 훅으로 어떤 하위 컴포넌트든 authState와 dispatch에 쉽게 접근해 인증 상태를 읽고 바꿀 수 있습니다. useAuth는 Provider 밖에서 호출되면 오류를 던지므로, 기본값 null이 잘못 쓰이는 일도 막아 줍니다.
반례를 고치는 순서
원래의 AuthContext 코드는 비교용으로 남겨 두고, 별도의 실습 파일에서 authReducer를 아래 함수로 바꿉니다. 기존의 세 액션과 알 수 없는 액션을 처리하는 방식은 그대로 두고, 저장소를 바꾸는 코드만 뺍니다.
const authReducer = (state, action) => {
switch (action.type) {
case 'LOGIN':
return { ...state, isLoggedIn: true, user: action.payload.user, token: action.payload.token };
case 'LOGOUT':
return { ...state, isLoggedIn: false, user: null, token: null };
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
};그다음 AuthProvider의 useReducer 선언 바로 아래, return보다 앞에 다음 Effect를 더합니다. useEffect는 원래 코드의 import에 이미 들어 있습니다. 이 변형도 브라우저 저장소를 쓸 수 있는 실습 환경과 올바른 LOGIN 페이로드를 전제로 합니다.
useEffect(() => {
try {
if (authState.isLoggedIn) {
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('userToken', authState.token);
} else {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('userToken');
}
} catch (error) {
console.error('Failed to persist auth state', error);
}
}, [authState.isLoggedIn, authState.token]);이제 리듀서는 다음 상태만 계산하고, 저장소를 바꾸는 일은 반영된 상태를 보고 Effect가 합니다. 같은 저장값으로 여러 번 동기화해도 로그인 횟수가 쌓이지는 않습니다. 저장에 실패했다면 화면의 상태가 바뀐 것과 저장에 성공한 것은 서로 다른 결과이므로 Console과 저장소를 함께 확인합니다. 저장된 문자열이나 화면의 로그인 표시가 서버에서 인증되었다는 뜻은 아니라는 점도 기억해 두세요.
useReducer를 쓸 때는 상태의 모양, 액션의 이름, 리듀서의 순수성, 테스트할 수 있는지를 한 흐름으로 점검하면 언제 도입할지 판단하기 쉬워집니다.
정리
useReducer는(state, action) => newState모양의 순수한 리듀서와, 액션을 전달하는dispatch, 초기 상태로 이루어집니다.- 리듀서는 state를 직접 바꾸지 않고 새 값을 돌려주며, 로컬 스토리지 저장이나 요청 같은 부수 효과는 리듀서 밖의 Effect나 이벤트 처리 함수에서 합니다.
- 상태 전이가 복잡하거나 여러 하위 상태가 함께 바뀔 때, 상태 로직을 따로 떼어 테스트하고 싶을 때
useReducer가 잘 맞습니다. useReducer(reducer, initialArg, init)의 세 번째 인자로 초기화 함수를 넘겨 지연 초기화할 수 있습니다.dispatch는 언제나 같은 참조이므로 Context와 함께 쓰면 하위 컴포넌트가 전역 상태를 읽고 바꾸게 할 수 있습니다.
연습 문제
1. ID 2와 6
todoReducer에 ID 2와 6인 두 할 일이 담긴 배열을 넣고, ID 6을 지운 뒤 새 할 일을 추가합니다. 새 할 일의 ID와 completed 값은 무엇일까요? 지우기 전에 넘긴 원래 배열의 ID 목록은 어떻게 될까요?
해설 보기
ID 6을 지우면 남은 가장 큰 ID는 2이므로 새 ID는 3이고, 새 항목의 completed는 false입니다. 지우기 전에 넘긴 원래 배열의 ID는 여전히 2와 6입니다. 리듀서는 새 배열을 돌려줄 뿐 원래 배열에서 항목을 지우거나 새 항목을 밀어 넣지 않습니다.
2. 순수한 authReducer의 동작
순수하게 고친 authReducer와 저장용 Effect를 쓰는 상태에서, 로그아웃 상태에서 LOGIN을 보내 사용자 이름과 토큰을 바꾼 뒤 LOGOUT을 보냅니다. 각 단계에서 상태와 저장소는 어떻게 바뀔까요? 리듀서 함수만 따로 호출해 결과를 확인할 때 저장소가 바뀌어야 할까요?
해설 보기
LOGIN 다음의 상태는 isLoggedIn이 true이고 넘긴 user와 token을 가집니다. 그 상태가 반영된 뒤 Effect가 isLoggedIn과 userToken 두 키를 저장합니다. LOGOUT 다음의 상태는 isLoggedIn이 false, user와 token이 null이며, Effect가 두 키를 지웁니다.
리듀서 함수만 호출하는 검사에서는 저장소가 바뀌지 않아야 합니다. 그래야 리듀서를 순수 함수로 테스트할 수 있습니다. 처음에 값을 읽어 복원하는 일은 기존의 initAuth가 맡으며, 이 변형도 실제 서버에서 사용자와 토큰을 검증하는 기능은 더하지 않습니다.
3. 로그인한 뒤 새로고침하면
원래의 AuthContext로 “로그인” 버튼을 눌러 “환영합니다, 테스트유저님!”을 확인한 뒤 페이지를 새로고침하면, 화면에는 어떤 이름이 보일까요?
해설 보기
“환영합니다, Logged In User님!”이 보입니다. 로컬 스토리지에는 isLoggedIn과 userToken만 저장되고 사용자의 이름은 저장되지 않으므로, initAuth가 예시용으로 만든 { name: 'Logged In User' }를 사용자로 씁니다.
저장된 값만으로 상태를 복원하면 이렇게 실제와 다른 정보가 보일 수 있습니다. 실제 앱이라면 저장된 토큰으로 서버에 사용자 정보를 다시 요청하고, 서버가 그 토큰을 검증해야 합니다.
다음 글 커스텀 훅 만들기에서는 반복되는 상태 로직을 재사용할 수 있는 단위로 떼어 내는 커스텀 훅을 다룹니다.