useReducer
우리는 useContext 훅을 통해 컴포넌트 간 데이터 공유를 더 효율적으로 처리하는 방법을 배웠습니다.
이제 useState 훅만으로는 감당하기 어려운 복잡한 상태 로직을 관리할 때 유용한 핵심 훅, useReducer 훅을 알아볼 차례입니다.
useState는 단순한 값(숫자, 문자열, 불리언)이나 독립적인 객체/배열 상태를 다룰 때 매우 직관적이고 편리합니다.
하지만 여러 하위 상태가 서로 연관되어 있거나 상태 변경 로직이 복잡해 여러 setState 호출이 필요한 경우, useState만으로는 코드가 길어지고 유지보수가 어려워질 수 있습니다.
이럴 때 useReducer가 빛을 발합니다.
useReducer는 Redux와 같은 상태 관리 라이브러리의 핵심 개념인 리듀서 패턴을 리액트 훅으로 가져온 것입니다.
이를 통해 상태 업데이트 로직을 컴포넌트에서 분리해, 더 예측 가능하고 테스트하기 쉬운 코드를 작성할 수 있습니다.
useReducer의 기본 개념
useReducer 훅은 세 가지 주요 요소로 구성됩니다.
reducer 함수(state, action) => newState형태를 가지는 순수 함수(Pure Function)입니다.- 현재 상태(
state)와 발생한action을 인자로 받아서, 그action에 따라 새로운 상태(newState)를 반환합니다. reducer함수 내부에서는state를 직접 변경해서는 안 되며(불변성 유지), 항상 새로운 상태 객체를 반환해야 합니다.reducer는 사이드 이펙트(Side Effect)를 가지면 안 됩니다. (useEffect에서 사이드 이펙트를 처리합니다.)
dispatch 함수useReducer가 반환하는 함수입니다.dispatch(action)형태로 호출하여reducer함수에게action을 전달합니다.action은 일반적으로{ type: 'ACTION_TYPE', payload: 'data' }와 같은 객체 형태를 가집니다.
initialState (초기 상태)useReducer에 전달하는 초기 상태 값입니다.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함수를 호출하여reducer에게INCREMENT액션을 전달합니다.state.count로 현재 카운트 값을 접근합니다.
useState vs useReducer
| 특징 | useState | useReducer |
|---|---|---|
| 복잡성 | 단순한 값 또는 독립적인 여러 값 | 복잡한 상태 로직, 여러 하위 상태가 연관된 경우 |
| 가독성 | 간단한 상태 변경 시 직관적 | 상태 변경 로직이 분리되어 있어 더 예측 가능하고 테스트 용이 |
| 업데이트 | setSomething(newValue) | dispatch({ type: 'ACTION' }) |
| 데이터 타입 | 숫자, 문자열, 불리언, 객체, 배열 등 모든 타입 | 일반적으로 객체나 배열 (여러 상태를 포함) |
| 코드 분리 | 상태 로직이 컴포넌트 내부에 존재 | 상태 로직(reducer)을 컴포넌트 외부로 분리 가능 |
| 장점 | 간단하고 배우기 쉬움 | 상태 로직의 예측 가능성, 중앙 집중화, 디버깅 용이 |
| 단점 | 복잡한 상태 변경 시 코드 지저분해짐 | 간단한 상태에도 보일러플레이트 코드 증가 |
useReducer를 사용하는가?
- 상태 전이(State Transitions)가 복잡할 때: 현재 상태에 따라 다음 상태가 여러 갈래로 결정되는 경우. (예: 로딩/성공/실패 상태)
- 여러 하위 상태가 서로 연관되어 함께 업데이트되어야 할 때: (예: 폼의 여러 필드 값, 장바구니 항목의 수량과 총액)
- 상태 로직을 컴포넌트로부터 분리하고 싶을 때:
reducer함수를 별도의 파일로 분리하여 재사용성을 높이거나 테스트를 용이하게 할 수 있습니다. - 컴포넌트 간에
dispatch함수를 전달해야 할 때:dispatch함수는useCallback으로 감쌀 필요 없이 항상 동일한 참조를 보장하므로Context와 함께 사용하기 좋습니다.
useReducer를 활용한 투두(Todo) 리스트
3장 실습에서 만들었던 Todo List 앱의 상태 관리 로직을 useReducer로 변경하여 복잡한 상태 관리의 이점을 느껴봅시다.
todoReducer.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)
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를 통해 필요한 데이터를 전달받습니다.- 모든 상태 업데이트는 불변성을 유지하도록 새로운 배열/객체를 반환합니다.
TodoListWithReducer.js 컴포넌트
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;App.js에서 사용
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;useReducer의 초기화 함수
useState와 마찬가지로 useReducer도 초기 상태를 지연 초기화할 수 있습니다. 이는 초기 상태 계산에 비용이 많이 드는 경우 유용합니다. useReducer의 세 번째 인자로 초기화 함수를 전달하면 됩니다.
const [state, dispatch] = useReducer(reducer, initialArg, init);init함수는initialArg를 인자로 받아서 실제 초기 상태 값을 반환합니다.- 이 함수는 컴포넌트가 처음 렌더링될 때만 실행됩니다.
위 Todo List 예제에서 init 함수를 사용하여 로컬 스토리지에서 할 일 목록을 불러오는 로직을 지연 초기화로 구현했습니다.
useReducer와 useContext 함께 사용하기
useReducer로 관리되는 상태와 dispatch 함수를 useContext를 통해 하위 컴포넌트에 전달하면, 전역적인 복잡한 상태 관리를 효율적으로 구현할 수 있습니다. 이는 Redux와 같은 전역 상태 관리 라이브러리의 내부 동작 방식과 유사합니다.
AuthContext와 useReducer 결합
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 함수에 쉽게 접근하여 인증 상태를 읽고 변경할 수 있게 됩니다.
useReducer: 복잡한 상태 관리는 여기까지입니다. 이 장에서는 useReducer 훅의 개념, reducer 함수와 dispatch 함수의 역할, useState와의 차이점, 그리고 복잡한 상태 로직을 효율적으로 관리하기 위한 useReducer의 활용법을 Todo List 재구현 예시를 통해 상세하게 설명했습니다. 마지막으로 useReducer와 useContext를 결합하여 전역 상태 관리를 구현하는 패턴까지 살펴보았습니다.
이제 여러분은 useState만으로는 부족했던 상황에서 useReducer를 사용하여 더 예측 가능하고 유지보수하기 쉬운 상태 관리 코드를 작성할 수 있게 되었습니다. 다음 절에서는 반복되는 상태 로직을 재사용 가능한 단위로 분리하는 커스텀 훅 만들기를 다루겠습니다.