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/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
컴포넌트
// src/components/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
에서 사용
// src/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
결합
// src/contexts/AuthContext.js
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;
};
// src/App.js (AuthProvider 사용 예시)
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
를 사용하여 더 예측 가능하고 유지보수하기 쉬운 상태 관리 코드를 작성할 수 있게 되었습니다. 다음 장에서는 useRef
훅을 이용하여 DOM 요소에 직접 접근하는 방법과 그 활용 사례에 대해 알아보겠습니다.