커스텀 훅은 비즈니스/뷰 로직을 깔끔하게 재사용하도록 만드는 가장 실용적인 수단입니다. 이 글에서는 실무에서 바로 쓰는 기준, API 설계 원칙, 비동기/구독/성능/테스트/타이핑까지 커버하는 설계 패턴과 예시 코드를 제공합니다.
1. 언제 커스텀 훅을 만들까
- 컴포넌트들 사이에 반복되는 상태/이펙트/콜백이 있을 때입니다.
- DOM이나 외부 상태(브라우저, 스토어)를 구독해야 할 때입니다.
- 복잡한 비동기 흐름(취소, 중복 요청, 로딩/에러 관리)이 필요할 때입니다.
2. 기본 원칙 체크리스트
- 훅 이름은 use로 시작합니다(useXxx).
- 훅 내부에서 다른 훅 호출은 항상 동일한 순서를 보장합니다(조건부 호출 금지).
- 훅은 UI를 반환하지 않습니다(JSX 리턴 금지). 데이터, 상태, 콜백만 노출합니다.
- 입력은 가능하면 options 객체로, 출력은 객체 형태로(확장성/가독성).
- 외부에 노출하는 콜백/객체는 참조가 안정적이도록 관리합니다.
3. API 디자인: 입력/출력
- 입력: options 객체 패턴 권장({delay, onError, deps 등}).
- 출력: 상태+액션을 객체로 반환({value, loading, error, run}).
- 간단 토글 예시:
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
const setTrue = useCallback(() => setOn(true), []);
const setFalse = useCallback(() => setOn(false), []);
return { on, toggle, setOn, setTrue, setFalse };
}
4. 최신 값/안정 콜백 패턴(useLatest/useEvent)
의존성 배열에 함수를 넣으면 참조가 자주 바뀔 수 있습니다. 내부적으로 최신 함수를 참조하되 바깥에는 안정적인 함수를 노출하세요.
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
export function useLatest(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
});
return ref;
}
// 안정적인 이벤트 콜백(useEvent 대체 패턴)
export function useStableCallback(fn) {
const latest = useLatest(fn);
return useCallback((...args) => latest.current?.(...args), [latest]);
}
// SSR 안전한 LayoutEffect
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
5. 비동기 훅 설계: 취소/중복/경쟁 상태
- AbortController로 이전 요청 취소하기.
- 최신 요청만 반영하기(경쟁 상태 방지).
- fetcher는 AbortSignal을 받아야 합니다.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
/**
* useAsync
* fetcher: ({ signal, args }) => Promise<T>
*/
export function useAsync(fetcher, { immediate = true, deps = [] } = {}) {
const latestFetcher = useLatest(fetcher);
const abortRef = useRef(null);
const [state, setState] = useState({ loading: immediate, data: undefined, error: undefined });
const run = useCallback(async (...args) => {
// 이전 요청 취소
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
setState(s => ({ ...s, loading: true, error: undefined }));
try {
const data = await latestFetcher.current({ signal: controller.signal, args });
if (!controller.signal.aborted) {
setState({ loading: false, data, error: undefined });
}
return data;
} catch (e) {
if (!controller.signal.aborted) {
setState({ loading: false, data: undefined, error: e });
}
throw e;
}
}, [latestFetcher]);
useEffect(() => {
if (immediate) run();
return () => abortRef.current?.abort();
// deps는 사용자가 메모이즈한 안정 배열을 전달해야 합니다.
}, [run, immediate, ...deps]);
// 참조 안정성을 위해 run만 새로워질 때에만 객체가 바뀌도록 메모이즈
return useMemo(() => ({ ...state, run }), [state, run]);
}
// 사용 예시
// const { data, loading, error, run } = useAsync(
// ({ signal }) => fetch(url, { signal }).then(r => r.json()),
// { deps: [url] }
// );
6. 디바운스 훅: 값 vs 콜백
목적에 따라 값 디바운싱과 콜백 디바운싱을 구분하세요.
import { useEffect, useMemo, useRef, useState } from 'react';
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export function useDebouncedCallback(fn, delay = 300) {
const latest = useLatest(fn);
return useMemo(() => {
let id;
const debounced = (...args) => {
if (id) clearTimeout(id);
id = setTimeout(() => latest.current(...args), delay);
};
debounced.cancel = () => id && clearTimeout(id);
return debounced;
}, [delay, latest]);
}
7. 외부 상태 구독(useSyncExternalStore)
브라우저/스토어 등 외부 소스 구독에는 useSyncExternalStore를 사용해 동시성에 안전한 구독을 구현합니다.
import { useSyncExternalStore } from 'react';
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // SSR 기본값
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
8. SSR/환경 고려 사항
- window/document 접근은 가드 처리(typeof window !== 'undefined').
- useLayoutEffect는 SSR에서 경고가 나므로 useIsomorphicLayoutEffect로 대체.
- 이벤트 리스너/타이머/observer는 반드시 cleanup 반환.
9. 성능: 리렌더 최소화
- 반환 객체/함수는 useMemo/useCallback으로 참조를 안정화합니다.
- 큰 데이터에서 필요한 조각만 선택해 반환합니다(selector 패턴).
- useEffect deps는 최소화하고, 최신 값은 useLatest로 참조합니다.
- 불필요한 state 파편화 지양(서로 의존적이면 하나의 state로 묶기).
10. 테스트: renderHook로 동작 보장
React Testing Library의 renderHook으로 커스텀 훅을 단위 테스트합니다.
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
test('useToggle toggles boolean', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.on).toBe(false);
act(() => result.current.toggle());
expect(result.current.on).toBe(true);
});
11. 타입스크립트 타이핑 팁
- 제네릭으로 입력/출력 타입을 노출합니다.
- 반환 객체는 명시적 타입을 부여해 DX를 높입니다.
// useAsync TS 시그니처 예시
export function useAsync<TArgs extends any[], TData>(
fetcher: (ctx: { signal: AbortSignal; args: TArgs }) => Promise<TData>,
options?: { immediate?: boolean; deps?: ReadonlyArray<unknown> }
): {
data: TData | undefined;
loading: boolean;
error: unknown;
run: (...args: TArgs) => Promise<TData>;
} { /* ...구현은 위 JS 참고... */ }
마무리 체크리스트
- [ ] 훅 이름은 use로 시작합니다.
- [ ] 조건부 훅 호출이 없습니다.
- [ ] 입력은 options 객체, 출력은 객체(상태+액션)입니다.
- [ ] 노출 콜백/객체는 참조가 안정적입니다(useCallback/useMemo).
- [ ] 비동기 취소/경쟁 상태를 처리합니다(AbortController).
- [ ] 외부 구독은 useSyncExternalStore를 사용합니다.
- [ ] SSR/브라우저 차이를 고려했습니다.
- [ ] 테스트와 타입을 제공합니다.
위 패턴만 지켜도 커스텀 훅은 예측 가능하고 재사용성이 높아집니다. 팀 컨벤션에 맞춰 옵션/반환 형태를 표준화하고, 공통 훅을 꾸준히 라이브러리화해 생산성을 끌어올리세요.
'React' 카테고리의 다른 글
| React Suspense로 데이터 로딩 경험 개선하기 (0) | 2026.04.14 |
|---|---|
| React에서 Virtualized List 구현하기 (0) | 2026.04.13 |
| React와 TypeScript를 함께 사용할 때의 베스트 프랙티스 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기2 (0) | 2026.04.10 |
| React에서 Context API로 전역 상태 관리하기 (0) | 2026.04.10 |