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 함수가 추가된 타입입니다.
useRef로 fetcher를 저장하는 이유가 있습니다. 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.data는 ApiTodo[]로 추론됩니다. 상태를 좁히면 데이터에 안전하게 접근합니다.
제네릭 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
useLocalStorage와 useFetch는 어떤 타입에도 쓸 수 있는 범용 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 서버를 만듭니다. 프론트엔드와 백엔드가 타입을 공유하는 방법도 다룹니다.