react-hook-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Hook Patterns
React Hook 模式
Overview
概述
Reference guide for writing correct, composable, and type-safe custom hooks in React + TypeScript. Apply these patterns when building or reviewing hooks to avoid stale closures, missing cleanup, and dependency array bugs.
这是一份在React + TypeScript中编写正确、可组合且类型安全的自定义Hook的参考指南。在构建或评审Hook时应用这些模式,以避免出现过时闭包、缺失清理操作和依赖数组相关的bug。
Dependency Array Correctness
依赖数组的正确性
The Rule
规则
Every reactive value (props, state, context, or anything derived from them) used inside a hook callback must appear in the dependency array. The ESLint rule catches most issues — never suppress it without documenting why.
react-hooks/exhaustive-depstsx
// GOOD: all reactive values in deps
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
// BAD: missing dep — title will be stale
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, []); // eslint-disable-line — NEVER do this
}Hook回调中使用的每个响应式值(props、state、context或由它们派生的任何值)都必须出现在依赖数组中。ESLint规则可以捕获大多数问题——除非有明确的文档说明原因,否则不要禁用该规则。
react-hooks/exhaustive-depstsx
// 良好示例:所有响应式值都在依赖中
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
// 不良示例:缺少依赖——title会过时
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, []); // eslint-disable-line — 绝对不要这样做
}Stable References Reduce Deps
稳定引用减少依赖项
Use for values you want to read without triggering re-runs.
useReftsx
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Update ref on every render — no dependency needed in the interval
useLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}使用存储你希望读取但不触发重新渲染的值。
useReftsx
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// 在每次渲染时更新ref——interval中不需要依赖
useLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}Cleanup Patterns
清理操作模式
Every effect that subscribes, connects, or allocates must return a cleanup function.
tsx
// Event listeners
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Abort controller for fetch
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [url]);
// Timers
useEffect(() => {
const id = setTimeout(() => setVisible(false), duration);
return () => clearTimeout(id);
}, [duration]);
// Intersection Observer
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [options]);所有进行订阅、连接或分配资源的effect都必须返回一个清理函数。
tsx
// 事件监听器
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// 用于fetch的Abort控制器
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [url]);
// 计时器
useEffect(() => {
const id = setTimeout(() => setVisible(false), duration);
return () => clearTimeout(id);
}, [duration]);
// 交叉观察器
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [options]);Stale Closure Prevention
过时闭包的预防
Closures capture variables at creation time. When a callback is stored (e.g., in an event listener or timer), it can read stale state.
tsx
// BAD: stale closure — count is captured at 0
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // Always sets to 1
}, 1000);
return () => clearInterval(id);
}, []); // count not in deps
}
// FIX 1: Functional updater (preferred for state-only)
setCount((prev) => prev + 1);
// FIX 2: Ref for non-state values
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // Always fresh
}, 1000);
return () => clearInterval(id);
}, []);闭包会在创建时捕获变量。当回调被存储(例如在事件监听器或计时器中)时,它可能会读取到过时的state。
tsx
// 不良示例:过时闭包——count在创建时被捕获为0
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 总是设置为1
}, 1000);
return () => clearInterval(id);
}, []); // count不在依赖中
}
// 修复方案1:函数式更新(仅针对state的首选方案)
setCount((prev) => prev + 1);
// 修复方案2:使用Ref存储非state值
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // 始终是最新值
}, 1000);
return () => clearInterval(id);
}, []);Event Handlers in Effects
Effect中的事件处理函数
tsx
// BAD: handler captures stale onSubmit
useEffect(() => {
form.addEventListener("submit", onSubmit);
return () => form.removeEventListener("submit", onSubmit);
}, []); // onSubmit missing
// GOOD: ref-based pattern
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
useEffect(() => {
const handler = (e: Event) => onSubmitRef.current(e);
form.addEventListener("submit", handler);
return () => form.removeEventListener("submit", handler);
}, []);tsx
// 不良示例:处理函数捕获了过时的onSubmit
useEffect(() => {
form.addEventListener("submit", onSubmit);
return () => form.removeEventListener("submit", onSubmit);
}, []); // 缺少onSubmit
// 良好示例:基于Ref的模式
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
useEffect(() => {
const handler = (e: Event) => onSubmitRef.current(e);
form.addEventListener("submit", handler);
return () => form.removeEventListener("submit", handler);
}, []);TypeScript Generics in Hooks
Hook中的TypeScript泛型
Use generics to make hooks reusable across types while preserving inference.
tsx
// Generic hook with constraint
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key]
);
return [stored, setValue] as const;
}
// Usage — T is inferred from initialValue
const [user, setUser] = useLocalStorage("user", { name: "", age: 0 });
// user is { name: string; age: number }使用泛型让Hook可以跨类型复用,同时保留类型推断能力。
tsx
// 带约束的泛型Hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key]
);
return [stored, setValue] as const;
}
// 使用方式——T会从initialValue自动推断
const [user, setUser] = useLocalStorage("user", { name: "", age: 0 });
// user的类型是 { name: string; age: number }Constrained Generics
带约束的泛型
tsx
function useApiResource<T extends { id: string }>(
fetcher: () => Promise<T[]>
) {
const [items, setItems] = useState<T[]>([]);
const [byId, setById] = useState<Map<string, T>>(new Map());
const refresh = useCallback(async () => {
const data = await fetcher();
setItems(data);
setById(new Map(data.map((item) => [item.id, item])));
}, [fetcher]);
return { items, byId, refresh } as const;
}tsx
function useApiResource<T extends { id: string }>(
fetcher: () => Promise<T[]>
) {
const [items, setItems] = useState<T[]>([]);
const [byId, setById] = useState<Map<string, T>>(new Map());
const refresh = useCallback(async () => {
const data = await fetcher();
setItems(data);
setById(new Map(data.map((item) => [item.id, item])));
}, [fetcher]);
return { items, byId, refresh } as const;
}Common Hook Recipes
常见Hook实现示例
useDebounce
useDebounce
tsx
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const debouncedSearch = useDebounce(searchTerm, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);tsx
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// 使用方式
const debouncedSearch = useDebounce(searchTerm, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);useFetch with TypeScript
带TypeScript的useFetch
tsx
type FetchState<T> =
| { status: "idle"; data: undefined; error: undefined }
| { status: "loading"; data: undefined; error: undefined }
| { status: "success"; data: T; error: undefined }
| { status: "error"; data: undefined; error: Error };
function useFetch<T>(url: string | null): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "idle",
data: undefined,
error: undefined,
});
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ status: "loading", data: undefined, error: undefined });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) =>
setState({ status: "success", data, error: undefined })
)
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", data: undefined, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
// Usage — discriminated union enables safe access
const result = useFetch<User[]>("/api/users");
if (result.status === "success") {
result.data.map(/* TypeScript knows data is User[] */);
}tsx
type FetchState<T> =
| { status: "idle"; data: undefined; error: undefined }
| { status: "loading"; data: undefined; error: undefined }
| { status: "success"; data: T; error: undefined }
| { status: "error"; data: undefined; error: Error };
function useFetch<T>(url: string | null): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "idle",
data: undefined,
error: undefined,
});
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ status: "loading", data: undefined, error: undefined });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) =>
setState({ status: "success", data, error: undefined })
)
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", data: undefined, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
// 使用方式——可辨识联合类型支持安全访问
const result = useFetch<User[]>("/api/users");
if (result.status === "success") {
result.data.map(/* TypeScript知道data是User[]类型 */);
}useMediaQuery
useMediaQuery
tsx
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}tsx
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}Composing Hooks
Hook的组合
Build complex behavior by composing small hooks. Each hook should do one thing.
tsx
// Small, focused hooks
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
};
}, []);
return isOnline;
}
// Composed hook
function useResilientFetch<T>(url: string) {
const isOnline = useOnlineStatus();
const [fetchKey, setFetchKey] = useState(0);
const result = useFetch<T>(isOnline ? url : null, fetchKey);
const debouncedRetry = useDebounce(isOnline, 2000);
const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
// Auto-retry when coming back online (debounced)
useEffect(() => {
if (debouncedRetry && result.status === "error") {
refetch();
}
}, [debouncedRetry, result.status, refetch]);
return { ...result, isOnline, refetch };
}通过组合小型Hook来构建复杂行为。每个Hook应该只负责一件事。
tsx
// 小型、专注的Hook
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
};
}, []);
return isOnline;
}
// 组合后的Hook
function useResilientFetch<T>(url: string) {
const isOnline = useOnlineStatus();
const [fetchKey, setFetchKey] = useState(0);
const result = useFetch<T>(isOnline ? url : null, fetchKey);
const debouncedRetry = useDebounce(isOnline, 2000);
const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
// 重新联网时自动重试(带防抖)
useEffect(() => {
if (debouncedRetry && result.status === "error") {
refetch();
}
}, [debouncedRetry, result.status, refetch]);
return { ...result, isOnline, refetch };
}When to Extract a Custom Hook
何时提取自定义Hook
The "3 Uses" Rule
"3次使用"规则
Extract a custom hook when:
- The same +
useStatecombo appears in 3+ componentsuseEffect - A component has 5+ hooks that serve a single concern
- You need to test stateful logic independently of UI
Do NOT extract a hook for:
- A single — just use
useStatedirectlyuseState - Logic used in only one component — a helper function is simpler
- "Just in case we need it later" — YAGNI
在以下情况时提取自定义Hook:
- 相同的+
useState组合出现在3个及以上组件中useEffect - 一个组件包含5个及以上服务于同一关注点的Hook
- 你需要独立于UI测试有状态的逻辑
以下情况不要提取Hook:
- 仅单个——直接使用
useState即可useState - 仅在一个组件中使用的逻辑——使用辅助函数更简单
- "以防以后需要"——不要做超前设计(YAGNI原则)
Anti-patterns
反模式
Hooks with Too Many Parameters
参数过多的Hook
More than 3 parameters signals the hook is doing too much. Use an options object.
tsx
// BAD: positional params are unclear
useDataFetcher(url, true, false, 5000, "GET", authToken);
// GOOD: options object with defaults
useDataFetcher(url, {
auth: true,
retry: false,
timeout: 5000,
});参数超过3个意味着这个Hook负责的事情太多。应该使用选项对象。
tsx
// 不良示例:位置参数含义不清晰
useDataFetcher(url, true, false, 5000, "GET", authToken);
// 良好示例:带默认值的选项对象
useDataFetcher(url, {
auth: true,
retry: false,
timeout: 5000,
});Missing Cleanup
缺失清理操作
If your effect subscribes, it must unsubscribe. Forgetting cleanup causes memory leaks and ghost updates after unmount.
如果你的effect进行了订阅,就必须取消订阅。忘记清理会导致内存泄漏,以及组件卸载后出现幽灵更新。
Stale Closures in Event Handlers
事件处理函数中的过时闭包
If you pass a callback to inside without a ref, the callback will read stale values when the effect does not re-run.
addEventListeneruseEffect如果你在内部将回调传递给但没有使用Ref,当effect不重新运行时,回调会读取到过时的值。
useEffectaddEventListenerHooks That Do Too Much
职责过多的Hook
A hook managing form state, validation, submission, and error display is four hooks. Split along concerns: , , .
useFormStateuseValidationuseSubmit一个同时管理表单状态、验证、提交和错误展示的Hook其实是四个Hook。应该按照关注点拆分:、、。
useFormStateuseValidationuseSubmit