react-hook-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React 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
react-hooks/exhaustive-deps
catches most issues — never suppress it without documenting why.
tsx
// 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-deps
可以捕获大多数问题——除非有明确的文档说明原因,否则不要禁用该规则。
tsx
// 良好示例:所有响应式值都在依赖中
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
useRef
for values you want to read without triggering re-runs.
tsx
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]);
}
使用
useRef
存储你希望读取但不触发重新渲染的值。
tsx
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:
  1. The same
    useState
    +
    useEffect
    combo appears in 3+ components
  2. A component has 5+ hooks that serve a single concern
  3. You need to test stateful logic independently of UI
Do NOT extract a hook for:
  • A single
    useState
    — just use
    useState
    directly
  • Logic used in only one component — a helper function is simpler
  • "Just in case we need it later" — YAGNI
在以下情况时提取自定义Hook:
  1. 相同的
    useState
    +
    useEffect
    组合出现在3个及以上组件
  2. 一个组件包含5个及以上服务于同一关注点的Hook
  3. 你需要独立于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
addEventListener
inside
useEffect
without a ref, the callback will read stale values when the effect does not re-run.
如果你在
useEffect
内部将回调传递给
addEventListener
但没有使用Ref,当effect不重新运行时,回调会读取到过时的值。

Hooks That Do Too Much

职责过多的Hook

A hook managing form state, validation, submission, and error display is four hooks. Split along concerns:
useFormState
,
useValidation
,
useSubmit
.
一个同时管理表单状态、验证、提交和错误展示的Hook其实是四个Hook。应该按照关注点拆分:
useFormState
useValidation
useSubmit