iBetter Books
수정

Ch 06. 커스텀 Hook 만들기

앞 챕터까지 만든 코드를 돌아보면 TodoContext.tsx가 꽤 무거워졌습니다. 상태 관리, API 호출, 로컬 스토리지 연동까지 한 파일에 모여 있으면 각 기능을 이해하기도, 테스트하기도 힘들어집니다.

커스텀 Hook은 로직을 재사용 가능한 단위로 분리하는 방법입니다. TypeScript와 함께 쓰면 제네릭 덕분에 어떤 타입의 데이터에도 쓸 수 있는 범용 Hook을 만들 수 있습니다.

useLocalStorage Hook

새로고침해도 데이터가 유지되도록 로컬 스토리지와 연동하는 Hook입니다.

// 새 파일: src/hooks/useLocalStorage.tsimport { useState, useEffect, useCallback } from 'react';function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {  const [storedValue, setStoredValue] = useState<T>(() => {    try {      const item = window.localStorage.getItem(key);      return item ? (JSON.parse(item) as T) : initialValue;    } catch {      return initialValue;    }  });  useEffect(() => {    try {      window.localStorage.setItem(key, JSON.stringify(storedValue));    } catch {      // 스토리지가 가득 차거나 시크릿 모드에서 실패할 수 있음    }  }, [key, storedValue]);  const setValue = useCallback((value: T) => {    setStoredValue(value);  }, []);  return [storedValue, setValue];}export default useLocalStorage;

반환 타입 [T, (value: T) => void]가 핵심입니다. 이것은 "길이가 2인 튜플"입니다. useState의 반환 타입과 같은 패턴입니다.

튜플 타입을 쓰는 이유가 있습니다. 일반 배열 타입 (T | ((value: T) => void))[]로 쓰면 구조 분해 할당 시 각 요소의 타입이 유니언이 됩니다. 튜플을 쓰면 첫 번째 요소는 T, 두 번째 요소는 (value: T) => void로 정확히 추론됩니다.

// 파일: 예시 (튜플 타입 차이 설명용)// 튜플 반환const [value, setValue] = useLocalStorage('key', 0);// value: number, setValue: (value: number) => void ✅// 배열 반환이었다면// value: number | ((value: number) => void) ❌

useFetch Hook

API 호출 로직을 재사용 가능하게 분리합니다.

// 새 파일: src/hooks/useFetch.tsimport { useState, useEffect, useRef } from 'react';import type { AsyncState } from '../types/async';interface UseFetchOptions {  skip?: boolean;}function useFetch<T>(  fetcher: () => Promise<T>,  options: UseFetchOptions = {}): AsyncState<T> & { refetch: () => void } {  const { skip = false } = options;  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });  const fetcherRef = useRef(fetcher);  fetcherRef.current = fetcher;  const [trigger, setTrigger] = useState(0);  useEffect(() => {    if (skip) return;    let cancelled = false;    setState({ status: 'loading' });    fetcherRef.current()      .then((data) => {        if (!cancelled) {          setState({ status: 'success', data });        }      })      .catch((err: unknown) => {        if (!cancelled) {          const error = err instanceof Error ? err : new Error(String(err));          setState({ status: 'error', error });        }      });    return () => {      cancelled = true;    };  }, [skip, trigger]);  const refetch = () => setTrigger((n) => n + 1);  return { ...state, refetch };}export default useFetch;

반환 타입 AsyncState<T> & { refetch: () => void }는 교차 타입입니다. AsyncState<T>의 모든 속성에 refetch 함수가 추가된 타입입니다.

useReffetcher를 저장하는 이유가 있습니다. fetcher가 매 렌더마다 새로 만들어지는 함수라면 useEffect의 의존성 배열에 넣을 수 없습니다. ref에 저장하면 최신 함수를 참조하면서 불필요한 재실행을 막을 수 있습니다.

skip 옵션은 조건부로 fetch를 건너뛸 때 사용합니다. 예를 들어 인증 토큰이 없을 때 API를 호출하지 않으려면 skip={!token}으로 씁니다.

useTodos Hook

Todo 앱의 핵심 로직을 한 곳에 모읍니다.

// 새 파일: src/hooks/useTodos.tsimport { useReducer, useEffect, useCallback, type Dispatch } from 'react';import type { Todo, TodoStatus, TodoAction } from '../types/todo';import { todoReducer, initialState } from '../context/todoReducer';import useLocalStorage from './useLocalStorage';interface UseTodosReturn {  todos: Todo[];  filter: TodoStatus;  filteredTodos: Todo[];  dispatch: Dispatch<TodoAction>;  stats: {    total: number;    completed: number;    active: number;  };}function useTodos(): UseTodosReturn {  const [savedTodos, setSavedTodos] = useLocalStorage<Todo[]>('todos', []);  const [state, dispatch] = useReducer(todoReducer, {    ...initialState,    todos: savedTodos,  });  // 상태가 바뀔 때마다 로컬 스토리지에 저장  useEffect(() => {    setSavedTodos(state.todos);  }, [state.todos]);  const filteredTodos = state.todos.filter((todo) => {    if (state.filter === 'active') return !todo.completed;    if (state.filter === 'completed') return todo.completed;    return true;  });  const stats = {    total: state.todos.length,    completed: state.todos.filter((t) => t.completed).length,    active: state.todos.filter((t) => !t.completed).length,  };  return {    todos: state.todos,    filter: state.filter,    filteredTodos,    dispatch,    stats,  };}export default useTodos;

UseTodosReturn 인터페이스로 Hook의 반환 타입을 명확히 선언했습니다. 이 Hook을 사용하는 컴포넌트는 타입 힌트를 통해 어떤 값과 함수를 사용할 수 있는지 바로 알 수 있습니다.

stats 객체는 계산된 값입니다. 컴포넌트에서 매번 계산하는 대신 Hook이 제공하므로 중복이 없어집니다.

TodoContext를 useTodos로 리팩토링

Context에서 직접 useReducer를 쓰는 대신 useTodos Hook을 활용하도록 Context를 단순화합니다.

// 수정: src/context/TodoContext.tsx
import {
  createContext,
  useContext,
  type ReactNode,
  type Dispatch,
} from 'react';
import type { TodoAction, TodoStatus, Todo } from '../types/todo';
import useTodos from '../hooks/useTodos';

interface TodoContextValue {
  todos: Todo[];
  filter: TodoStatus;
  filteredTodos: Todo[];
  dispatch: Dispatch<TodoAction>;
  stats: {
    total: number;
    completed: number;
    active: number;
  };
}

const TodoContext = createContext<TodoContextValue | undefined>(undefined);

export function TodoProvider({ children }: { children: ReactNode }) {
  const todoData = useTodos();

  return (
    <TodoContext.Provider value={todoData}>
      {children}
    </TodoContext.Provider>
  );
}

export function useTodoContext(): TodoContextValue {
  const context = useContext(TodoContext);
  if (context === undefined) {
    throw new Error('useTodoContext는 TodoProvider 안에서만 사용할 수 있습니다.');
  }
  return context;
}

Context는 이제 상태 관리 로직을 직접 갖지 않습니다. useTodos Hook이 로직을 담당하고, Context는 그 결과를 하위 컴포넌트에 전달하는 역할만 합니다.

완성된 App.tsx

API로 초기 데이터를 불러오는 기능을 useFetch와 연결합니다.

// 수정: src/App.tsx
import { useEffect } from 'react';
import { TodoProvider } from './context/TodoContext';
import { useTodoContext } from './context/TodoContext';
import TodoForm from './components/TodoForm';
import TodoFilter from './components/TodoFilter';
import TodoList from './components/TodoList';
import useFetch from './hooks/useFetch';
import { fetchTodos } from './api/todos';
import { apiTodoToTodo } from './types/todo';
import './App.css';

function AppContent() {
  const { dispatch, stats } = useTodoContext();

  const fetchState = useFetch(() => fetchTodos(10));

  useEffect(() => {
    if (fetchState.status === 'success' && fetchState.data.length > 0) {
      const todos = fetchState.data.map(apiTodoToTodo);
      dispatch({ type: 'SET_TODOS', payload: todos });
    }
  }, [fetchState.status]);

  return (
    <div className="app">
      <h1>할 일 앱</h1>
      <p className="stats">
        전체 {stats.total}개 · 완료 {stats.completed}개 · 미완료 {stats.active}개
      </p>
      <TodoForm />
      <TodoFilter />
      {fetchState.status === 'loading' && (
        <p className="loading-message">할 일을 불러오는 중입니다...</p>
      )}
      {fetchState.status === 'error' && (
        <p className="error-message">
          데이터를 불러오지 못했습니다. {fetchState.error.message}
        </p>
      )}
      <TodoList />
    </div>
  );
}

function App() {
  return (
    <TodoProvider>
      <AppContent />
    </TodoProvider>
  );
}

export default App;

fetchState.status === 'success' 블록 안에서 fetchState.dataApiTodo[]로 추론됩니다. 상태를 좁히면 데이터에 안전하게 접근합니다.

제네릭 Hook 패턴 정리

이 챕터에서 만든 세 Hook의 제네릭 패턴을 비교합니다.

// 파일: 예시 (제네릭 Hook 패턴 비교)// 저장 타입 T를 제네릭으로function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void]// 데이터 타입 T를 제네릭으로function useFetch<T>(fetcher: () => Promise<T>): AsyncState<T> & { refetch: () => void }// 반환 타입을 인터페이스로 명시function useTodos(): UseTodosReturn

useLocalStorageuseFetch는 어떤 타입에도 쓸 수 있는 범용 Hook입니다. 사용할 때 타입 인수를 명시하거나 추론에 맡깁니다.

useTodos는 이 앱 전용 Hook입니다. 반환 타입을 인터페이스로 명시해 소비하는 쪽에서 타입 힌트를 받을 수 있습니다.

최종 프로젝트 파일 구조

PART 07에서 만든 파일들입니다.

src/
├── api/
│   ├── client.ts        ← 제네릭 fetch 래퍼, ApiError
│   └── todos.ts         ← fetchTodos 함수
├── components/
│   ├── TodoFilter.tsx   ← 필터 버튼
│   ├── TodoForm.tsx     ← 할 일 추가 폼
│   ├── TodoItem.tsx     ← 할 일 항목
│   └── TodoList.tsx     ← 할 일 목록
├── context/
│   ├── TodoContext.tsx  ← Context + Provider + useTodoContext
│   └── todoReducer.ts  ← 리듀서 + 초기 상태
├── hooks/
│   ├── useFetch.ts      ← 제네릭 fetch Hook
│   ├── useLocalStorage.ts ← 제네릭 로컬 스토리지 Hook
│   └── useTodos.ts      ← Todo 앱 전용 Hook
├── types/
│   ├── async.ts         ← AsyncState<T>
│   └── todo.ts          ← Todo, TodoStatus, TodoAction, ApiTodo
├── App.tsx
├── App.css
├── index.css
└── main.tsx

정리

이 챕터에서 만든 것과 배운 개념을 정리합니다.

  • useLocalStorage<T>: 튜플 반환 타입으로 구조 분해 타입 안전성 확보
  • useFetch<T>: 교차 타입으로 기존 타입에 refetch 추가
  • useTodos: 앱 전용 Hook, 반환 타입을 인터페이스로 명시
  • useRef로 함수 참조를 안정화하는 패턴
  • Context를 단순한 전달자로 유지하고 로직은 Hook으로 분리

이제 PART 07의 할 일 앱이 완성되었습니다. 타입 정의, 컴포넌트 Props, 상태 관리, 이벤트 핸들러, API 연동, 커스텀 Hook까지 React + TypeScript의 핵심 패턴을 모두 적용했습니다.

다음 PART에서는 Node.js와 Express로 타입 안전한 REST API 서버를 만듭니다. 프론트엔드와 백엔드가 타입을 공유하는 방법도 다룹니다.