Custom Hooks 가이드 (React 공식 문서 기반)
React 공식 문서를 기반으로 작성된 Custom Hooks 가이드
Custom Hooks란?
Custom Hooks는 컴포넌트 간에 상태 로직(stateful logic)을 공유하기 위한 메커니즘입니다.
핵심 개념
// ✅ Custom Hook: 상태 로직을 재사용
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
중요: Custom Hooks는 상태 로직(logic)을 공유하지, 상태 자체(state)를 공유하지 않습니다.
function ComponentA() {
const isOnline = useOnlineStatus(); // 독립적인 상태
}
function ComponentB() {
const isOnline = useOnlineStatus(); // 독립적인 상태 (ComponentA와 다름)
}
왜 Custom Hooks를 사용하는가?
1. 코드 중복 제거
Before: 코드 중복
function ChatRoom() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return <div>{isOnline ? '✅ 연결된 상태' : '❌ 연결되지 않은 상태'}</div>;
}
function StatusBar() {
// 동일한 로직 반복...
const [isOnline, setIsOnline] = useState(true);
useEffect(() => { /* ... */ }, []);
return <div>{isOnline ? '🟢' : '🔴'}</div>;
}
After: Custom Hook으로 추출
// 한 번만 작성
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => { /* ... */ }, []);
return isOnline;
}
// 여러 곳에서 재사용
function ChatRoom() {
const isOnline = useOnlineStatus();
return <div>{isOnline ? '✅ Online' : '❌ Disconnected'}</div>;
}
function StatusBar() {
const isOnline = useOnlineStatus();
return <div>{isOnline ? '🟢' : '🔴'}</div>;
}
2. 관심사의 분리 (Separation of Concerns)
// ❌ 컴포넌트에 모든 로직이 섞여있음
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const [reviews, setReviews] = useState([]);
const [relatedProducts, setRelatedProducts] = useState([]);
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
useEffect(() => {
fetchReviews(productId).then(setReviews);
}, [productId]);
useEffect(() => {
fetchRelatedProducts(productId).then(setRelatedProducts);
}, [productId]);
// ...
}
// ✅ 각 관심사를 Custom Hook으로 분리
function ProductPage({ productId }) {
const product = useProduct(productId);
const reviews = useReviews(productId);
const relatedProducts = useRelatedProducts(productId);
// 컴포넌트는 UI 렌더링에만 집중
return (
<div>
<ProductInfo product={product} />
<ReviewList reviews={reviews} />
<RelatedProducts products={relatedProducts} />
</div>
);
}
3. 테스트 용이성
// Custom Hook을 독립적으로 테스트 가능
import { renderHook, act } from '@testing-library/react-hooks';
test('useCounter increments correctly', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Custom Hooks 작성 규칙
1. 네이밍 규칙 (Naming Convention)
필수: 반드시 use로 시작해야 합니다.
// ✅ 올바른 네이밍
function useFormInput() { }
function useOnlineStatus() { }
function useLocalStorage() { }
function usePrevious() { }
// ❌ 잘못된 네이밍
function getFormInput() { } // use로 시작하지 않음
function FormInputHook() { } // use로 시작하지 않음
function form_input_hook() { } // camelCase가 아님
이유: React는 use로 시작하는 함수를 Hook으로 인식하고, Rules of Hooks를 검증합니다.
2. Rules of Hooks
Custom Hooks도 일반 Hooks와 동일한 규칙을 따릅니다.
Rule 1: 최상위에서만 호출
// ❌ 조건문, 반복문, 중첩 함수 내에서 호출 금지
function useExample(condition) {
if (condition) {
const [state, setState] = useState(); // ❌ 조건부 호출
}
for (let i = 0; i < 10; i++) {
useEffect(() => {}); // ❌ 반복문 내 호출
}
}
// ✅ 최상위에서만 호출
function useExample(condition) {
const [state, setState] = useState();
useEffect(() => {
if (condition) {
// 조건부 로직은 Hook 내부에서
}
}, [condition]);
}
Rule 2: React 함수 내에서만 호출
// ❌ 일반 JavaScript 함수에서 호출 금지
function regularFunction() {
const [state, setState] = useState(); // ❌
}
// ✅ React 함수 컴포넌트에서 호출
function Component() {
const [state, setState] = useState(); // ✅
}
// ✅ Custom Hook 내에서 호출
function useCustomHook() {
const [state, setState] = useState(); // ✅
}
3. 순수 함수여야 함 (Pure Function)
// ❌ 부수 효과가 있는 Hook
let globalCache = {};
function useData(key) {
// Hook 실행 중 외부 상태 직접 변경 (부수 효과)
globalCache[key] = 'data';
return globalCache[key];
}
// ✅ 순수한 Hook
function useData(key) {
const [data, setData] = useState(null);
useEffect(() => {
// 부수 효과는 useEffect 내에서
fetchData(key).then(setData);
}, [key]);
return data;
}
실전 예제
예제 1: useLocalStorage
브라우저 localStorage와 동기화되는 상태 관리
import { useState, useEffect } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
// localStorage에서 초기값 가져오기
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// 값이 변경될 때 localStorage 업데이트
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue] as const;
}
// 사용 예시
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'ko');
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<select value={language} onChange={e => setLanguage(e.target.value)}>
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
);
}
예제 2: useDebounce
입력값 디바운싱
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// 사용 예시: 검색 API 호출 최적화
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm) {
// 500ms 후에만 API 호출
searchAPI(debouncedSearchTerm).then(setResults);
}
}, [debouncedSearchTerm]);
return (
<input
type="text"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="검색어 입력..."
/>
);
}
예제 3: usePrevious
이전 값 추적
import { useRef, useEffect } from 'react';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 사용 예시: 값 변화 감지
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>현재: {count}</p>
<p>이전: {prevCount}</p>
<p>
{prevCount !== undefined && count > prevCount
? '증가 ⬆️'
: '감소 ⬇️'}
</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}
예제 4: useFetch
데이터 페칭 로직 재사용
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): FetchState<T> {
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);
setError(null);
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(error => {
if (!cancelled) {
setError(error);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// 사용 예시
function UserProfile({ userId }: { userId: number }) {
const { data, loading, error } = useFetch<User>(
`https://api.example.com/users/${userId}`
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
예제 5: useForm
폼 상태 관리
import { useState, ChangeEvent } from 'react';
interface FormValues {
[key: string]: any;
}
interface UseFormReturn<T> {
values: T;
handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
handleSubmit: (callback: (values: T) => void) => (e: React.FormEvent) => void;
resetForm: () => void;
}
function useForm<T extends FormValues>(initialValues: T): UseFormReturn<T> {
const [values, setValues] = useState<T>(initialValues);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setValues({
...values,
[name]: type === 'checkbox' ? checked : value,
});
};
const handleSubmit = (callback: (values: T) => void) => (e: React.FormEvent) => {
e.preventDefault();
callback(values);
};
const resetForm = () => {
setValues(initialValues);
};
return {
values,
handleChange,
handleSubmit,
resetForm,
};
}
// 사용 예시
function LoginForm() {
const { values, handleChange, handleSubmit, resetForm } = useForm({
email: '',
password: '',
rememberMe: false,
});
const onSubmit = (data: typeof values) => {
console.log('Form submitted:', data);
// API 호출 등
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
name="email"
type="email"
value={values.email}
onChange={handleChange}
placeholder="이메일"
/>
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
placeholder="비밀번호"
/>
<label>
<input
name="rememberMe"
type="checkbox"
checked={values.rememberMe}
onChange={handleChange}
/>
로그인 상태 유지
</label>
<button type="submit">로그인</button>
<button type="button" onClick={resetForm}>초기화</button>
</form>
);
}
Best Practices
1. 구체적이고 집중된 Hook 만들기
// ❌ 너무 범용적인 Hook
function useLifecycle(onMount, onUpdate, onUnmount) {
useEffect(() => {
onMount();
return onUnmount;
}, []);
useEffect(() => {
onUpdate();
});
}
// ✅ 구체적인 목적을 가진 Hook
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
2. 명확한 네이밍
// ❌ 불분명한 이름
function useData() { }
function useValue() { }
function useHelper() { }
// ✅ 명확하고 설명적인 이름
function useUserProfile(userId: string) { }
function useShoppingCart() { }
function useAuthToken() { }
function useMediaQuery(query: string) { }
3. 반환 값 패턴
튜플 패턴: 단순한 값 2개
// useState와 유사한 패턴
function useToggle(initialValue: boolean = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(v => !v);
return [value, toggle] as const;
}
// 사용
const [isOpen, toggleOpen] = useToggle(false);
객체 패턴: 여러 값 또는 복잡한 API
// 여러 값을 반환할 때는 객체 사용
function usePagination(totalItems: number, itemsPerPage: number) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(totalItems / itemsPerPage);
return {
currentPage,
totalPages,
nextPage: () => setCurrentPage(p => Math.min(p + 1, totalPages)),
prevPage: () => setCurrentPage(p => Math.max(p - 1, 1)),
goToPage: (page: number) => setCurrentPage(page),
isFirstPage: currentPage === 1,
isLastPage: currentPage === totalPages,
};
}
// 사용 (필요한 것만 destructure)
const { currentPage, nextPage, prevPage, isLastPage } = usePagination(100, 10);
4. 의존성 배열 정확히 관리
// ❌ 의존성 누락
function useInterval(callback: () => void, delay: number) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [delay]); // ❌ callback 누락
}
// ✅ 모든 의존성 포함
function useInterval(callback: () => void, delay: number) {
const savedCallback = useRef(callback);
// callback이 변경되면 ref 업데이트
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
5. 타입 안정성 (TypeScript)
// ✅ 제네릭을 활용한 타입 안전성
function useArray<T>(initialValue: T[] = []) {
const [array, setArray] = useState<T[]>(initialValue);
const push = (element: T) => {
setArray(arr => [...arr, element]);
};
const filter = (callback: (item: T) => boolean) => {
setArray(arr => arr.filter(callback));
};
const update = (index: number, newElement: T) => {
setArray(arr => [
...arr.slice(0, index),
newElement,
...arr.slice(index + 1),
]);
};
const remove = (index: number) => {
setArray(arr => [
...arr.slice(0, index),
...arr.slice(index + 1),
]);
};
const clear = () => {
setArray([]);
};
return { array, set: setArray, push, filter, update, remove, clear };
}
// 사용 - 타입 추론 자동
const { array, push, remove } = useArray<string>(['a', 'b', 'c']);
push('d'); // ✅ OK
push(123); // ❌ Type error
6. 성능 최적화
import { useState, useCallback, useMemo } from 'react';
function useExpensiveHook(data: number[]) {
// ✅ 무거운 계산은 useMemo로 메모이제이션
const processedData = useMemo(() => {
return data.map(item => expensiveOperation(item));
}, [data]);
// ✅ 콜백 함수는 useCallback으로 메모이제이션
const handleOperation = useCallback((item: number) => {
return item * 2;
}, []);
return { processedData, handleOperation };
}
주의사항
1. 상태를 공유하지 않음
// Custom Hook은 로직만 공유, 상태는 독립적
function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
function ComponentA() {
const { count, increment } = useCounter(); // 독립적인 count
return <button onClick={increment}>{count}</button>;
}
function ComponentB() {
const { count, increment } = useCounter(); // 또 다른 독립적인 count
return <button onClick={increment}>{count}</button>;
}
// ComponentA와 ComponentB의 count는 서로 다름!
상태를 공유하려면 Context API 사용:
const CountContext = createContext();
function CountProvider({ children }) {
const counter = useCounter();
return (
<CountContext.Provider value={counter}>
{children}
</CountContext.Provider>
);
}
function useSharedCounter() {
return useContext(CountContext);
}
2. Effect를 데이터 흐름 제어에 사용하지 말 것
// ❌ Effect로 데이터 변환 (안티 패턴)
function useFilteredData(data: Item[]) {
const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => {
setFiltered(data.filter(item => item.active));
}, [data]);
return filtered;
}
// ✅ 직접 계산 또는 useMemo 사용
function useFilteredData(data: Item[]) {
return useMemo(() => {
return data.filter(item => item.active);
}, [data]);
}
3. Hook 호출 순서 유지
// ❌ 조건부 Hook 호출
function useExample(shouldFetch: boolean) {
if (shouldFetch) {
const data = useFetch('/api/data'); // ❌ 순서가 바뀔 수 있음
}
}
// ✅ 항상 호출하되, 조건은 내부에서 처리
function useExample(shouldFetch: boolean) {
const [data, setData] = useState(null);
useEffect(() => {
if (shouldFetch) {
fetch('/api/data').then(setData);
}
}, [shouldFetch]);
return data;
}
4. 무한 루프 주의
// ❌ 무한 루프 발생
function useBadExample() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // count가 변경되면 다시 실행 → 무한 루프
}, [count]);
}
// ✅ 의존성 배열 제거 또는 함수형 업데이트
function useGoodExample() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(c => c + 1); // 함수형 업데이트로 count 의존성 제거
}, []); // 한 번만 실행
}
실전 팁
1. Hook 조합하기
Custom Hooks는 다른 Hooks를 조합할 수 있습니다.
function useAuthUser() {
const { user } = useAuth();
const { data: profile } = useFetch(`/api/users/${user?.id}`);
const { data: permissions } = useFetch(`/api/users/${user?.id}/permissions`);
return {
user,
profile,
permissions,
isAdmin: permissions?.includes('admin'),
};
}
2. 디버깅을 위한 useDebugValue
import { useDebugValue } from 'react';
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
// React DevTools에 표시될 값
useDebugValue(isOnline ? 'Online' : 'Offline');
// ...
return isOnline;
}
3. ESLint 플러그인 사용
npm install eslint-plugin-react-hooks --save-dev
{
"extends": ["plugin:react-hooks/recommended"]
}
이 플러그인은 Rules of Hooks 위반을 자동으로 감지합니다.
참고 자료
- React 공식 문서 - Reusing Logic with Custom Hooks
- React 공식 문서 - Built-in React Hooks
- React Hooks API Reference
- Rules of Hooks
정리
Custom Hooks는 React의 강력한 기능으로, 다음과 같은 이점을 제공합니다.
- 재사용성: 로직을 여러 컴포넌트에서 재사용
- 가독성: 복잡한 로직을 명확한 이름의 Hook으로 추상화
- 테스트 용이성: 로직을 독립적으로 테스트
- 관심사의 분리: UI와 비즈니스 로직을 분리
핵심 원칙:
use로 시작하는 네이밍- Rules of Hooks 준수
- 순수 함수로 작성
- 구체적이고 집중된 목적
Custom Hooks를 잘 활용하면 더 깔끔하고 유지보수하기 쉬운 React 애플리케이션을 만들 수 있습니다.
댓글