React 교재 · 중급 12편

React 커스텀 Hook 만들기

같은 useEffect·useState 패턴이 여러 컴포넌트에 반복? Hook 으로 추출해 한 번에 정리.

부모 코드 에디터에서 작은 재사용 컴포넌트 아이콘이 추출되어 도구 상자에 들어가는 일러스트 — 커스텀 Hook 컨셉

11편 useEffect 의 데이터 fetch 예제 — 다음 컴포넌트에서 똑같이 또 쓰게 된다. 세 번째 컴포넌트에서도, 네 번째에서도. 같은 try·catch·loading state 5줄을 다섯 군데에 복붙하면 유지보수가 지옥. 답은 커스텀 Hook.

이번 12편은 커스텀 Hook 의 단 하나의 규칙 (use- prefix) + 실전 3개 (useFetch·useLocalStorage·useDebounce) + 회사 코드베이스에 두기 좋은 패턴까지.

1. 커스텀 Hook 은 "use 로 시작하는 함수" 일 뿐

특별한 문법 없음. 함수 이름이 use 로 시작 + 그 안에서 React Hook 을 호출하면 끝. ESLint 규칙 react-hooks 가 이 규칙으로 검사.

// useCounter.ts import { useState } from 'react'; export function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); const reset = () => setCount(initial); return { count, increment, decrement, reset }; } // 사용 function App() { const { count, increment, reset } = useCounter(0); return ( <> <p>{count}</p> <button onClick={increment}>+</button> <button onClick={reset}>reset</button> </> ); }

이게 커스텀 Hook 의 전부. 컴포넌트가 아니라 함수, 반환은 JSX 가 아니라 값/객체.

왜 use- prefix 가 강제인가 — React 가 Hook 의 호출 순서를 추적해 state 를 인스턴스별로 분리. use- 로 시작하지 않으면 React 의 정적 분석이 "이 함수가 Hook 인지" 모름 → Hook rule 위반 못 잡음. getCounter 라고 이름 지으면 컴파일은 되지만 ESLint 가 에러.

2. useFetch — 11편 useEffect 의 진짜 응용

11편의 fetch + loading + error 패턴을 Hook 으로 추출.

// useFetch.ts import { useState, useEffect } from 'react'; export function useFetch<T>(url: string) { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); useEffect(() => { let cancelled = false; setLoading(true); fetch(url) .then(r => r.json()) .then(json => { if (!cancelled) setData(json); }) .catch(err => { if (!cancelled) setError(err); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; // cleanup }, [url]); return { data, loading, error }; } // 사용 — 한 줄 function UserProfile({ id }) { const { data: user, loading, error } = useFetch<User>(`/api/users/${id}`); if (loading) return <Spinner />; if (error) return <ErrorMsg error={error} />; return <h1>{user.name}</h1>; }

cancelled 플래그가 핵심 — URL 이 빨리 바뀌면 옛 fetch 응답이 늦게 와서 새 데이터를 덮어쓰는 race condition 을 방지. cleanup function 으로 cancelled 를 true 로 → 옛 fetch 응답 무시.

3. useLocalStorage — useState 의 영속 버전

state 가 새로고침 후에도 유지되어야 할 때 (테마 설정·로그인 토큰·draft). useState 와 같은 인터페이스, 안은 localStorage.

// useLocalStorage.ts export function useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { const saved = localStorage.getItem(key); return saved ? JSON.parse(saved) : initial; }); useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue] as const; } // 사용 — useState 와 동일 인터페이스 function Settings() { const [theme, setTheme] = useLocalStorage('theme', 'light'); return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>; }

useState 초기값으로 함수를 전달하는 패턴 (lazy initialization). 컴포넌트가 마운트될 때 한 번만 localStorage 읽음. 매 렌더마다 읽으면 성능 손해.

4. useDebounce — 입력 폭주 흡수

검색 input — 사용자가 타이핑할 때마다 API 호출하면 안 됨. 0.5초 멈출 때까지 기다린 후 한 번만.

// useDebounce.ts export function useDebounce<T>(value: T, delay = 500): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); // value 가 또 바뀌면 옛 timer 취소 }, [value, delay]); return debounced; } // 사용 function Search() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 500); const { data } = useFetch(`/api/search?q=${debouncedQuery}`); // ← 디바운스된 값 return <input value={query} onChange={(e) => setQuery(e.target.value)} />; }

이 3 Hook 만 손에 익으면 React 코드량이 30% 줄어든다. 회사 코드베이스에 src/hooks/ 폴더 만들고 거기에 누적하는 게 표준 패턴.

꼭 직접 만들 필요는 없다usehooks-ts · react-use 같은 라이브러리가 50+ 커스텀 Hook 을 검증된 형태로 제공. 우리 stack 의 useFetch 는 TanStack Query 가 더 강력 (17편). 단, 학습 단계에선 직접 만들어봐야 어떻게 동작하는지 안다.

12편으로 React 의 컴포넌트·Hook 사고방식이 완성. 13편부터는 컴포넌트 간 데이터 공유 — prop drilling 을 끊는 Context API 로.

다음 글

React 교재 13편 — Context API. 전역 상태·prop drilling 탈출·useContext + Provider 패턴.

© 2026 주나이테크(주) @JUNAITECH