[React] 커스텀 Hook
개요
커스텀 Hook은 반복되는 상태 로직을 별도 함수로 분리해 재사용하는 패턴입니다. 이름이 반드시 use로 시작해야 하며, 내부에서 다른 Hook을 사용할 수 있습니다.
언제 만드나?
- 여러 컴포넌트에서 같은 로직이 반복될 때
- 컴포넌트가 너무 복잡해질 때 (로직과 UI 분리)
- 테스트하기 쉽게 로직을 분리하고 싶을 때
useFetch - API 데이터 가져오기
// src/hooks/useFetch.ts
import { useState, useEffect } from "react";
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false; // 언마운트 후 state 업데이트 방지
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP 오류: ${res.status}`);
const json: T = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "오류 발생");
}
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; }; // cleanup
}, [url]);
return { data, loading, error };
}
export default useFetch;
// 사용
import useFetch from "./hooks/useFetch";
interface User {
id: number;
name: string;
email: string;
}
function App() {
const { data: users, loading, error } = useFetch<User[]>(
"https://jsonplaceholder.typicode.com/users"
);
if (loading) return <p>로딩 중...</p>;
if (error) return <p>오류: {error}</p>;
return (
<ul>
{users?.map((user) => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
useLocalStorage - 로컬 스토리지 동기화
// src/hooks/useLocalStorage.ts
import { useState } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = (newValue: T | ((prev: T) => T)) => {
try {
const valueToStore = typeof newValue === "function"
? (newValue as (prev: T) => T)(value)
: newValue;
setValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.error("LocalStorage 저장 실패:", err);
}
};
return [value, setStoredValue] as const;
}
export default useLocalStorage;
// 사용
import useLocalStorage from "./hooks/useLocalStorage";
function App() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
const [name, setName] = useLocalStorage("userName", "");
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
테마: {theme}
</button>
</div>
);
}
useToggle - 불린 전환
// src/hooks/useToggle.ts
import { useState, useCallback } from "react";
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((prev) => !prev), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
export default useToggle;
// 사용
function App() {
const modal = useToggle(false);
const darkMode = useToggle(false);
return (
<div>
<button onClick={modal.toggle}>
{modal.value ? "모달 닫기" : "모달 열기"}
</button>
{modal.value && <div>모달 내용</div>}
<button onClick={darkMode.toggle}>
{darkMode.value ? "라이트 모드" : "다크 모드"}
</button>
</div>
);
}
useDebounce - 입력 지연
검색창처럼 입력이 잦을 때 API 호출 횟수를 줄입니다.
// src/hooks/useDebounce.ts
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // 입력이 다시 오면 이전 타이머 취소
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;
// 사용
import { useState } from "react";
import useDebounce from "./hooks/useDebounce";
import useFetch from "./hooks/useFetch";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500); // 500ms 지연
const { data, loading } = useFetch<any[]>(
debouncedQuery
? `https://jsonplaceholder.typicode.com/posts?q=${debouncedQuery}`
: ""
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어 입력 (0.5초 후 자동 검색)"
/>
{loading && <p>검색 중...</p>}
<p>결과: {data?.length ?? 0}개</p>
</div>
);
}
useWindowSize - 화면 크기
// src/hooks/useWindowSize.ts
import { useState, useEffect } from "react";
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;
}
export default useWindowSize;
커스텀 Hook 규칙
- 이름은 반드시
use로 시작 (React가 Hook임을 인식) - 최상위 레벨에서만 호출 (조건문, 반복문 안에서 호출 금지)
- 로직을 재사용하는 것이지, UI를 포함하지 않음 (JSX 반환 금지 권장)
src/hooks/폴더에 모아서 관리 (대규모 프로젝트 권장)