iBetter Books
수정

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를 만들 때 가장 흔한 실수는 초기값에 undefinednull을 넣고 소비하는 쪽에서 매번 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 })에서 payloadstring을 넣으면 타입 에러가 납니다. TOGGLE_TODOpayloadnumber여야 한다는 것을 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 함수의 타입

다음 챕터에서는 할 일을 추가하는 폼과 필터 버튼을 만들면서 이벤트 핸들러 타이핑을 다룹니다.