Ch 03. 상태 관리와 Context 타이핑
앞 챕터의 App.tsx를 보면 todos 상태와 핸들러 함수들이 한데 모여 있습니다. 지금은 컴포넌트가 두 개뿐이라 괜찮지만, 앱이 커지면 이 상태를 점점 깊은 곳으로 전달해야 합니다. Props를 3단계, 4단계 내려보내는 순간 "Props 드릴링"이 시작됩니다.
이 챕터에서는 useReducer로 상태 로직을 명확히 정리하고, Context로 어디서든 접근할 수 있게 만듭니다.
useReducer로 상태 정리하기
useState는 단순한 상태에 적합합니다. 하지만 "상태를 어떻게 바꾸는지"가 여러 군데 흩어져 있으면 유지보수가 힘들어집니다. useReducer는 상태 변경 로직을 한 곳에 모읍니다.
먼저 액션 타입을 정의합니다.
// 수정: src/types/todo.tsexport type TodoStatus = 'all' | 'active' | 'completed';export interface Todo { id: number; text: string; completed: boolean; createdAt: Date;}export interface TodoState { todos: Todo[]; filter: TodoStatus;}export type TodoAction = | { type: 'ADD_TODO'; payload: string } | { type: 'TOGGLE_TODO'; payload: number } | { type: 'DELETE_TODO'; payload: number } | { type: 'SET_FILTER'; payload: TodoStatus } | { type: 'SET_TODOS'; payload: Todo[] };
TodoAction은 유니언 타입입니다. 각 멤버는 type 프로퍼티로 구분됩니다. TypeScript는 if (action.type === 'ADD_TODO') 처럼 type을 좁히면 해당 멤버의 payload 타입도 자동으로 좁혀줍니다. 이것이 "판별 유니언(Discriminated Union)"입니다.
이제 리듀서를 만듭니다.
// 새 파일: src/context/todoReducer.tsimport type { TodoState, TodoAction } from '../types/todo';export const initialState: TodoState = { todos: [], filter: 'all',};export function todoReducer(state: TodoState, action: TodoAction): TodoState { switch (action.type) { case 'ADD_TODO': { const newTodo = { id: Date.now(), text: action.payload.trim(), completed: false, createdAt: new Date(), }; return { ...state, todos: [...state.todos, newTodo] }; } case 'TOGGLE_TODO': { const updatedTodos = state.todos.map((todo) => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo ); return { ...state, todos: updatedTodos }; } case 'DELETE_TODO': { const filteredTodos = state.todos.filter( (todo) => todo.id !== action.payload ); return { ...state, todos: filteredTodos }; } case 'SET_FILTER': return { ...state, filter: action.payload }; case 'SET_TODOS': return { ...state, todos: action.payload }; default: return state; }}
각 case에서 action.payload의 타입이 자동으로 좁혀집니다. ADD_TODO에서는 string, TOGGLE_TODO에서는 number가 됩니다. 잘못된 타입을 payload에 넘기면 컴파일 에러가 납니다.
switch 문에서 default 케이스는 반드시 있어야 합니다. 나중에 새로운 액션 타입이 추가되었을 때 처리를 빠뜨리지 않도록 돕습니다. TypeScript는 default에서 action의 타입이 never임을 확인할 수 있습니다.
Context 타이핑
Context를 만들 때 가장 흔한 실수는 초기값에 undefined나 null을 넣고 소비하는 쪽에서 매번 null 체크를 하는 패턴입니다. 이보다 깔끔한 방법이 있습니다.
// 새 파일: src/context/TodoContext.tsx
import {
createContext,
useContext,
useReducer,
type ReactNode,
type Dispatch,
} from 'react';
import type { TodoState, TodoAction } from '../types/todo';
import { todoReducer, initialState } from './todoReducer';
interface TodoContextValue {
state: TodoState;
dispatch: Dispatch<TodoAction>;
}
const TodoContext = createContext<TodoContextValue | undefined>(undefined);
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
export function useTodoContext(): TodoContextValue {
const context = useContext(TodoContext);
if (context === undefined) {
throw new Error('useTodoContext는 TodoProvider 안에서만 사용할 수 있습니다.');
}
return context;
}
핵심 패턴이 두 가지 있습니다.
첫째, createContext<TodoContextValue | undefined>(undefined)로 초기값을 undefined로 둡니다. 이렇게 하면 Provider 없이 Context를 사용할 때를 타입으로 표현할 수 있습니다.
둘째, useTodoContext 커스텀 Hook에서 undefined 체크를 한 번만 합니다. 이 Hook을 사용하는 모든 곳에서 context는 항상 TodoContextValue 타입이 됩니다. null 체크를 반복할 필요가 없습니다.
Dispatch<TodoAction> 타입은 (action: TodoAction) => void와 같습니다. useReducer가 반환하는 dispatch 함수의 타입입니다.
컴포넌트에서 Context 사용
이제 App.tsx를 깔끔하게 정리합니다.
// 수정: src/App.tsx
import { TodoProvider } from './context/TodoContext';
import TodoList from './components/TodoList';
import './App.css';
function AppContent() {
return (
<div className="app">
<h1>할 일 앱</h1>
<TodoList />
</div>
);
}
function App() {
return (
<TodoProvider>
<AppContent />
</TodoProvider>
);
}
export default App;
TodoList는 이제 Props 없이 Context에서 직접 상태를 가져옵니다.
// 수정: src/components/TodoList.tsx
import { useTodoContext } from '../context/TodoContext';
import TodoItem from './TodoItem';
function TodoList() {
const { state, dispatch } = useTodoContext();
const filteredTodos = state.todos.filter((todo) => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
const handleToggle = (id: number) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
const handleDelete = (id: number) => {
dispatch({ type: 'DELETE_TODO', payload: id });
};
if (filteredTodos.length === 0) {
return (
<div className="empty-state">
<p>할 일이 없습니다. 새로운 할 일을 추가해보세요.</p>
</div>
);
}
return (
<ul className="todo-list">
{filteredTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
);
}
export default TodoList;
dispatch({ type: 'TOGGLE_TODO', payload: id })에서 payload에 string을 넣으면 타입 에러가 납니다. TOGGLE_TODO의 payload는 number여야 한다는 것을 TypeScript가 알고 있기 때문입니다.
useReducer의 타입 추론
useReducer의 타입 시그니처를 보면 이해가 깊어집니다.
// 파일: 예시 (타입 설명용)function useReducer<S, A>( reducer: (state: S, action: A) => S, initialState: S): [S, Dispatch<A>];
todoReducer의 인수 타입이 (state: TodoState, action: TodoAction) => TodoState이므로, TypeScript는 자동으로 S = TodoState, A = TodoAction으로 추론합니다. 제네릭을 명시하지 않아도 됩니다.
정리
이 챕터에서 만든 것과 배운 개념을 정리합니다.
TodoAction: 판별 유니언으로 타입 안전한 액션 정의todoReducer: 각 케이스에서payload타입이 자동으로 좁혀짐TodoContext+TodoProvider: Context에 정확한 타입 부여useTodoContext: null 체크를 한 번만 하는 커스텀 Hook 패턴Dispatch<TodoAction>: dispatch 함수의 타입
다음 챕터에서는 할 일을 추가하는 폼과 필터 버튼을 만들면서 이벤트 핸들러 타이핑을 다룹니다.