커스텀 hook = 이름이 use 로 시작하고 다른 hook 호출하는 함수. 규칙 전부. 이름 관례가 linter 의 Rules of Hooks 강제 가능하게.
커스텀 hook 자격 생긴 시점
같은 hook 기반 작업 하는 컴포넌트 둘 이상. 중복이 JSX 가 아니라 useState + useEffect + cleanup 춤. 그 춤을 함수로 추출. 두 컴포넌트가 이제 함수 호출하고 렌더링에 집중.
이름
useChat, useMousePosition, useDebouncedValue, useLocalStorage. use prefix 필수 — React linter 한테 이 함수가 Rules of Hooks 따른다고 알림 (상단에서 호출, 조건 안에서 절대 안 됨). Prefix 없으면 linter 가 강제 못 함.
커스텀 hook 이 반환하는 것
원하는 거 무엇이든 — 보통 object 또는 tuple. 모양이 hook 의 API 계약:
Object: const { messages, send, isStreaming } = useChat(conversationId) — 이름 붙은 출력 여러 개일 때.
반환 값 둘 넘으면 object 써. 이름 붙은 필드가 가독성에서 positional ordering 이김.
cwkPippa 예시
cwkPippa 의 useChat 은 SQLite 백킹 conversation 을 타입 잡힌 계약으로 감싸는 진짜 커스텀 hook: messages, send, regenerate, isStreaming, error. 컴포넌트는 fetch URL, message parent ID, SSE parsing 안 알아도 — 그냥 useChat(conversationId) 호출하고 반환된 object 읽어. Track 5 lesson 7 이 구현 walk; 이 레슨은 왜 + 모양.
호출자 둘, hook 하나. '둘의 규칙' 적용. 한 컴포넌트만 이 로직 필요하면 추출이 과설계일 수 있어. 두 번째 호출자 올 때까지 기다리고 그 후 리팩토링. 조기 hook 추출은 OOP 의 조기 추상화의 React 사촌.
Code
useLocalStorage — state 를 localStorage 와 동기화·tsx
import { useEffect, useState } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (v: T) => void] {
// Lazy initializer 가 localStorage 한 번 읽음.
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initialValue;
} catch {
return initialValue;
}
});
// value 바뀔 때마다 localStorage 에 write back.
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// 쿼터 초과 또는 storage 비활성 — 조용히 무시.
}
}, [key, value]);
return [value, setValue];
}
// 사용 — useState 와 정확히 같지만 reload 간에 persist.
function ThemePicker() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "dark");
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Switch to {theme === "dark" ? "light" : "dark"}
</button>
);
}
useDebouncedValue — 작고 자주 필요한 hook·tsx
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
// 사용 — 사용자가 타이핑 멈춘 후에만 발화하는 검색 input.
function Search({ query }: { query: string }) {
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery) fetch(`/api/search?q=${debouncedQuery}`);
}, [debouncedQuery]);
return null;
}
CSS 미디어 쿼리 매치 여부 반환 + viewport 바뀔 때 업데이트하는 useMediaQuery(query: string): boolean 빌드. window.matchMedia + 이벤트 리스너 사용. 두 컴포넌트에서 사용 — 하나는 mobile vs desktop 레이아웃 렌더, 다른 하나는 'small screen' 경고 표시. 중복 사라지고 둘 다 깨끗하게 읽히는지 확인.
Hint
const mql = window.matchMedia(query); setMatches(mql.matches); 초기, 그 후 mql.addEventListener('change', handler) 로 라이브 업데이트. Cleanup 이 리스너 제거.
Progress
Progress is local-only — sign in to sync across devices.