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 패턴.