커스텀 훅은 상태와 effect를 함께 쓰는 로직에 이름을 붙여 재사용하는 방법이야. 컴포넌트 모양까지 숨기지 않고 동작만 나누는 기준을 익혀.
다른 훅을 호출하는 함수야
커스텀 훅은 이름이 use로 시작하고 내부에서 useState, useEffect, useContext 같은 훅을 호출해. React가 훅 호출 규칙을 검사할 수 있도록 일반 함수와 구분하는 약속이야. 훅을 전혀 호출하지 않는 계산이라면 일반 함수로 두면 돼.
컴포넌트에서 동작만 꺼내
화면 코드 안에 상태, 구독, cleanup이 뒤섞이면 관련 부분을 하나의 훅으로 옮겨. 예를 들어 useChat은 메시지 상태와 전송, 스트림 취소를 맡고 컴포넌트는 반환된 값으로 화면을 그려. JSX 자체는 컴포넌트에 남기므로 화면 구조가 숨지 않아.
작은 훅을 조합해
커스텀 훅 안에서 다른 커스텀 훅을 호출해도 돼. useChat이 useConversation과 useStreamingResponse를 조합하는 식이야. 다만 호출 순서는 항상 같아야 하므로 조건문 안에서 일반 훅을 호출하지 마.
반환 값은 사용하는 쪽의 계약이야
한두 값이면 튜플도 괜찮지만 여러 기능을 돌려줄 때는 이름 있는 객체가 읽기 쉬워. 내부 구현을 바꾸더라도 컴포넌트가 의존하는 이름과 의미는 안정적으로 유지해.
두 곳에서 같은 훅 묶음을 반복할 때 추출해. 코드 줄 수가 아니라 상태와 생명주기의 책임이 하나로 묶이는지가 기준이야.
로직은 공유하지만 상태 인스턴스는 공유하지 않아
두 컴포넌트가 같은 커스텀 훅을 호출하면 코드와 규칙은 재사용하지만 각각 자기 state와 effect를 가져. 실제 값을 공유하려면 Context나 외부 store처럼 별도의 소유자가 필요해.
LocalStorage와 debounce가 좋은 연습이야
useLocalStorage는 초기 값을 저장소에서 읽고 state 변경을 다시 기록해. 브라우저 저장소를 읽을 수 없는 환경과 JSON parse 실패도 처리해야 해. useDebouncedValue는 값이 바뀔 때 타이머를 새로 만들고 cleanup에서 이전 타이머를 취소해 마지막 값만 반영해.
훅의 규칙은 내부에서도 그대로 적용돼
커스텀 훅이라고 조건문 안에서 useState를 호출할 수 있는 건 아니야. 모든 렌더링에서 같은 순서로 훅을 호출하고, 조건은 훅이 반환한 값을 사용하는 쪽에서 표현해.
Code
useLocalStorage: 상태를 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에 다시 저장해.
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// 저장 공간이 부족하거나 storage를 쓸 수 없으면 기존 상태를 유지해.
}
}, [key, value]);
return [value, setValue];
}
// useState와 같은 모양으로 사용하지만 새로고침 뒤에도 값이 남아.
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: 작고 자주 필요한 훅·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;
}
useMediaQuery(query: string): boolean 훅을 만들어 window.matchMedia의 현재 일치 여부를 읽고 viewport가 바뀔 때 갱신해. 한 컴포넌트는 mobile과 desktop layout을 바꾸고, 다른 컴포넌트는 small-screen 경고를 표시하도록 같은 훅을 각각 호출해. DevTools에서 viewport를 경계 앞뒤로 바꿨을 때 두 소비자가 동시에 갱신되는지 확인하고, 한 소비자를 unmount한 뒤에는 제거된 listener가 더는 실행되지 않는지도 관찰해.
Hint
mql.matches로 초기 값을 설정하고 mql.addEventListener('change', handler)를 등록해. Cleanup에서는 같은 handler를 제거하고, 두 소비자가 중복된 matchMedia 코드를 갖지 않는지와 mount·change·unmount 세 단계의 호출 수를 확인해.
Progress
Progress is local-only — sign in to sync across devices.