
12 个常用 React Hook,9 个有坑:按现象对号入座 原创
先说几个现象。
页面上选的明明是「已完成」,列表里躺的却是待付款的订单。 刷新一下就好了,再点几下又冒出来,本地怎么都复现不了。
Toast 弹出来就赖着不走。 说好 3 秒自动消失的,它偏不。
用户反馈复制按钮点了没反应。 你自己点,又是好的。
本地跑得好好的,一部署就白屏。 控制台留下一句 localStorage is not defined。
如果这里面有一条你眼熟,那多半不是业务代码写错了,而是项目里某个自定义 Hook 有问题。
更巧的是,这些 Hook 你大概率不是自己写的——是从某份《N 个必备 React Hook》清单里抄来的。这类清单网上到处都是:代码短、命名好、看着干净,抄起来毫无心理负担。
问题恰恰出在这儿。一段四行的 Hook,人扫一眼就觉得"这有什么好错的",于是跳过审查直接复制。 而自定义 Hook 偏偏是最能藏东西的地方——它把复杂度收进了一个很干净的函数名后面,调用方看到的是 useFetch(url),看不到里面有没有处理竞态。
下面这 12 个 Hook,是各类清单里出现频率最高的。我把它们放进 React 19.2 + TypeScript strict 环境逐个写了测试,结论是:
只有 3 个能直接用,另外 9 个都得改,其中 4 个建议整个重写。
下面不按 Hook 名字排,按你可能遇到的现象排。对号入座就行。每一节都是先把问题讲明白,代码放在后面——不熟 TypeScript 也能读完。
(所有实现都通过了 strict 编译零报错 + 41 项运行时断言,关键结论都跑了对照组。)
现象一:选的是 A,显示的是 B
罪魁祸首:useFetch
先看它长什么样
tsfunction useFetch(url) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, [url]);
return data;
}四行,教科书一样整齐。几乎每份 Hook 清单里都有它。
为什么会显示错的数据
设想一个订单列表页,顶部一排筛选 Tab:全部、待付款、待发货、已完成。
用户手快,先点了「待付款」,紧接着点「已完成」。Tab 切换没有防抖,两个请求几乎同时飞出去。
按常理,最后点的那个说了算。但网络不讲常理:
「待付款」数据量大、没命中缓存,后端查了 820 毫秒;「已完成」正好命中缓存,90 毫秒就回来了。
code点「待付款」─┬─ 请求A ━━━━━━━━━━━━━━━━━━━━━► 820ms 才回
│
点「已完成」─┴─ 请求B ━━━━━━━━► 90ms 就回
│ │
▼ ▼
先渲染「已完成」 被「待付款」覆盖 ❌B 先回来,页面渲染出正确结果。然后 A 慢悠悠地到了,又调了一次 setData——把对的盖成了错的。
最后用户看到:Tab 高亮停在「已完成」,下面列表全是待付款订单。
换个说法可能更直观:你在外卖平台先点了麻辣烫,等太久,改点了汉堡。结果两单都在跑。汉堡先到,你吃上了;二十分钟后麻辣烫也送到,还硬塞进你手里——桌上最后剩的是麻辣烫。
问题的根子在于:useEffect 的清理函数只管"取消订阅",它管不了已经飞出去的请求。你切了 Tab,旧请求并不知道自己过期了,回来照样往页面上写。
这也解释了为什么本地复现不了。开发环境接口都是几十毫秒,两个请求的先后顺序几乎不会乱。一到线上,用户网络千奇百怪、后端缓存冷热不均,它才开始出现。等有人报上来,前端不报错、后端日志干净、测试环境点不出来——排查方向很容易一上来就跑偏。
顺带还有三个雷
没判断 res.ok。 后端返 404 或 500 时,网关通常吐一个 HTML 错误页。res.json() 一解析就抛错,下面又没有 .catch,直接变成 unhandled rejection 飘进监控,页面则永远停在空白。
分不清"在加载"和"加载失败"。 调用方只能靠 data === null 猜。可 null 到底是还没回来、失败了,还是后端就返回了个 null?猜不出来的直接后果是——骨架屏做不了,失败重试按钮也做不了。
组件卸载了请求还在跑。 用户点进去又秒退,请求照样占着连接。一个页面十几个这种 Hook,来回切几次,浏览器并发连接就被没人要的请求占满了。
怎么修
思路一句话:给每个请求配一张"取消单"。 effect 清理的时候把单子递出去,这个请求当场作废,回来的数据也不会再往页面上写。
浏览器提供的这张取消单叫 AbortController。
tsimport { useEffect, useState } from "react";
/** 四种状态写成可辨识联合,TS 会在分支里自动收窄类型 */
type FetchState<T> =
| { status: "idle" | "loading"; data: null; error: null }
| { status: "success"; data: T; error: null }
| { status: "error"; data: null; error: Error };
export function useFetch<T>(url: string | null, init?: RequestInit) {
const [state, setState] = useState<FetchState<T>>({
status: "idle",
data: null,
error: null,
});
useEffect(() => {
// url 传 null 表示"条件还不满足,先别请求"
if (!url) return;
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
fetch(url, { ...init, signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return (await res.json()) as T;
})
.then((data) => setState({ status: "success", data, error: null }))
.catch((err: unknown) => {
// 被主动取消的不算错误,咽掉
if (err instanceof DOMException && err.name === "AbortError") return;
setState({
status: "error",
data: null,
error: err instanceof Error ? err : new Error(String(err)),
});
});
// 关键:url 一变、组件一卸载,上一个请求立刻作废
return () => controller.abort();
}, [url]);
return state;
}用起来:
tsxfunction OrderList() {
const [tab, setTab] = useState<OrderStatus>("all");
const { status, data, error } = useFetch<Order[]>(`/api/orders?status=${tab}`);
return (
<>
<Tabs value={tab} onChange={setTab} />
{status === "loading" && <TableSkeleton />}
{status === "error" && <RetryTip message={error.message} />}
{status === "success" && <Table rows={data} />}
</>
);
}status === "success" 那个分支里,data 的类型是 Order[],不是 Order[] | null——省掉一堆 data! 和 data?.map。
对照测试:用可控延迟模拟上面的场景(旧请求 300ms、新请求 30ms),原版最终渲染的是旧请求的数据;修复版里旧请求在切换瞬间就被取消了,最终数据始终是最新的。
最后提醒两句。
一是 init 我故意没放进依赖数组。它是个对象字面量,调用方每次渲染都造一个新的,放进依赖就等于每次渲染都重发请求——这是自定义 Hook 里最经典的死循环来源。真要动态 headers,用 JSON.stringify(init) 当依赖,或者把 init 提到组件外面。
这里有个更普适的教训:Hook 的依赖数组正不正确,不取决于 Hook 内部写了什么,取决于调用方传进来的东西稳不稳定。 代码交出去那一刻,控制权就不在你手上了。
二是——项目里已经有 TanStack Query 或 SWR 的话,别自己写这个。 缓存、失焦重新验证、请求去重、退避重试,这些迟早都要。自己往上加,加到第三版你会发现是在造一个功能更少、bug 更多的 React Query。上面这份实现的定位很明确:内部工具页、小项目、不想为几个请求引依赖。
现象二:Toast 赖着不走,倒计时永远数不完
罪魁祸首:useTimeout
这是整篇里最阴的一个,因为它在任何 demo 里都表现正常。
tsfunction useTimeout(callback, delay) {
useEffect(() => {
const timer = setTimeout(callback, delay);
return () => clearTimeout(timer);
}, [callback, delay]);
}callback 进了依赖数组。而现实中 99% 的调用是这么写的:
tsxuseTimeout(() => setToastVisible(false), 3000);内联箭头函数——每次渲染都是一个新函数。React 比较依赖时只看引用,新函数 = 依赖变了 = effect 重跑一遍:
code父组件每次重新渲染
│
▼
() => setToastVisible(false) ← 又是一个新函数
│
▼
useEffect 发现依赖 [callback] 变了
│
├─► clearTimeout(旧计时器) ⏱ 归零
└─► setTimeout(新计时器) ⏱ 从头开始数
│
▼
只要父组件还在重渲染,这 3 秒就永远数不完打个比方:你定了个 3 分钟的闹钟准备眯一会儿。可每次有同事路过工位,闹钟就被重新拨回 3 分钟。同事走得勤一点,这闹钟永远不会响。
所以一个"操作成功"的 Toast,本该 3 秒后消失,但只要它所在的页面上有个每秒刷新的实时数据、或者一个监听鼠标移动的埋点,父组件持续重渲染——它就赖在屏幕上不走了。
同样的坑还会出现在:支付倒计时(数到一半被重置,永远不超时)、登录态失效提醒(永远不弹)、新手引导蒙层自动关闭(关不掉)。
对照测试:让父组件每 10ms 重渲染一次、跑满 120ms。原版的回调一次都没触发;修复版正常触发了 1 次。
怎么修
思路:闹钟本身别动,只换里面那张写着"要做什么"的纸条。
具体做法是把 callback 存进 ref。ref 的特点是——改它不会触发重渲染,也不会让 effect 觉得依赖变了。
tsimport { useEffect, useRef } from "react";
/** delay 传 null 表示暂停计时(比如 Toast 被鼠标悬停住) */
export function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// 每次渲染都把最新的 callback 塞进 ref,但不惊动下面那个 effect
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}delay 支持传 null 这个口子很实用。Toast 鼠标移上去暂停倒计时、移开继续,就是一行 useTimeout(close, hovered ? null : 3000)。
现象三:本地好好的,一部署就白屏
罪魁祸首:useLocalStorage 和 useWindowSize
这两个我放一节讲,因为病根是同一个。
ts// useLocalStorage 里
const [value] = useState(() => localStorage.getItem(key));
// useWindowSize 里
const [size] = useState({ width: window.innerWidth, height: window.innerHeight });都是在 useState 的初始化里,直接伸手去摸浏览器对象。
纯客户端项目没问题。但只要项目上了 Next.js、Remix 这类服务端渲染框架,这行代码就会先在服务器上跑一遍——而服务器上没有浏览器,没有 window,没有 localStorage。直接 ReferenceError,页面白屏。
这就好比你写了张便条给同事:"出门左转第三个柜子里拿。" 结果这张便条被送到了另一栋楼——那儿根本没有柜子。
更麻烦的是第二堵墙。就算你加了 typeof window !== "undefined" 绕过报错,还会撞上水合不匹配(hydration mismatch):服务端渲染出的是默认值,客户端首屏读的是存储里的真实值,React 一对比发现两边对不上,控制台一片红,严重时整棵组件树重来一遍。
修 useLocalStorage
思路:首屏老老实实渲染默认值,等挂载完成之后再把真实值同步进来。 两边都从默认值出发,就不会不匹配。
tsimport { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
function read<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
return raw === null ? fallback : (JSON.parse(raw) as T);
} catch {
// 存进去的不是合法 JSON(被别的代码写脏了),退回默认值
return fallback;
}
}
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(initialValue);
const [hydrated, setHydrated] = useState(false);
// 挂载后读一次真实值
useEffect(() => {
setValue(read(key, initialValue));
setHydrated(true);
}, [key]);
// 回写。hydrated 之前不写,避免用默认值覆盖掉用户已有的数据
useEffect(() => {
if (!hydrated) return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// 无痕模式 / 配额写满,静默降级成纯内存状态
}
}, [key, value, hydrated]);
// 多标签页同步
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === key) setValue(read(key, initialValue));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key]);
return [value, setValue];
}改完白捡两个能力。
一是直接返回 React 原生的 setValue,setCount(c => c + 1) 这种函数式更新自动就支持了——原版那个手写的 updateValue 不支持,这个坑在"点赞 +1"、"购物车数量增减"这类连续操作里会连着踩。
二是那个 storage 事件监听。运营同事开了两个标签页,在 A 页面改了表格筛选条件,B 页面能立刻跟上,不用刷新。
还有一件比上面所有问题都重要的事。
很多清单会建议用它存 authentication token。千万别。
localStorage 对页面上任何一段 JavaScript 都是明文可读的——包括你引进来的第三方统计脚本,包括某个被投毒的 npm 依赖。一旦有 XSS,token 就是白送。
这好比把家门钥匙用便利贴贴在门口。你自己是方便了,路过的人也方便。 登录态请交给 httpOnly 的 Cookie,那把钥匙 JS 根本碰不到。这个 Hook 拿去存主题色、侧边栏折叠状态、表格列宽、最近搜索记录,正合适。
修 useWindowSize
除了 SSR 崩溃,它还有个性能问题:resize 事件不做任何节流,用户拖动窗口边缘时,每移动一像素就触发一次 setState。
典型受害者是数据大屏。一屏十几个 ECharts,每个都监听窗口尺寸重算布局,用户把窗口从半屏拖到全屏,这一秒内几百次重渲染,页面卡成 PPT。
React 18 给这类"订阅浏览器数据"的需求提供了官方解法:useSyncExternalStore。它一次解决两个问题——强制你提供服务端快照(SSR 不崩),并且只在数据真变了的时候才触发渲染。
tsimport { useSyncExternalStore } from "react";
type Size = { width: number; height: number };
function subscribe(onStoreChange: () => void) {
window.addEventListener("resize", onStoreChange);
return () => window.removeEventListener("resize", onStoreChange);
}
// 关键:快照必须是稳定引用。
// 每次都返回新对象的话,React 会认为数据一直在变,直接无限重渲染。
// 所以尺寸没变时要复用上一个对象。
let cachedSize: Size = { width: 0, height: 0 };
function getSnapshot(): Size {
if (
cachedSize.width !== window.innerWidth ||
cachedSize.height !== window.innerHeight
) {
cachedSize = { width: window.innerWidth, height: window.innerHeight };
}
return cachedSize;
}
const serverSnapshot: Size = { width: 0, height: 0 };
export function useWindowSize(): Size {
return useSyncExternalStore(subscribe, getSnapshot, () => serverSnapshot);
}那个 cachedSize 的比较不是强迫症,是硬性要求。
为什么? React 判断"数据变没变"靠的是引用比较。你每次都递给它一个新对象,就像它每次问"现在几点",你都塞给它一块新买的手表——它只会认为时间一直在变,于是不停重渲染。少了这几行,页面会以 60fps 无限重渲染,报错信息(The result of getSnapshot should be cached)第一次见的人往往反应不过来。
最后一句提醒:多数"响应式"需求根本不该用这个 Hook。 断点判断交给 CSS 媒体查询,元素尺寸监听交给 ResizeObserver。真正需要在 JS 里拿窗口尺寸的场景比想象中少得多——canvas 画布尺寸、虚拟列表算可视区高度,差不多就这些。
现象四:页面越用越卡,内存下不去
罪魁祸首:useOnlineStatus
tsuseEffect(() => {
window.addEventListener("online", () => setOnline(true));
window.addEventListener("offline", () => setOnline(false));
}, []);没有清理函数。而且就算想补也补不上——传进去的是匿名箭头函数,removeEventListener 压根拿不到同一个引用来解绑。
这类 Hook 常见于仓库 PDA 的扫码页、配送员 App、地铁上用的移动端表单——都需要"当前是否离线"的提示,也都会在一个 SPA 里反复进出。
用户来回切 20 次,window 上就永久挂着 40 个再也摘不掉的监听器。每一个都死死攥着对应那次渲染的 setOnline 闭包,连带着整棵组件树的内存都释放不掉。
顺带,useState(navigator.onLine) 在服务端渲染阶段同样会崩,跟现象三一个道理。
怎么修
还是 useSyncExternalStore。它有个很妙的设计:订阅函数必须返回一个"退订函数",你想漏都漏不掉。
tsimport { useSyncExternalStore } from "react";
// subscribe 必须定义在组件外面,保证是稳定引用,
// 否则每次渲染都会重新订阅一遍
function subscribe(onStoreChange: () => void) {
window.addEventListener("online", onStoreChange);
window.addEventListener("offline", onStoreChange);
return () => {
window.removeEventListener("online", onStoreChange);
window.removeEventListener("offline", onStoreChange);
};
}
export function useOnlineStatus(): boolean {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // 浏览器端读真实值
() => true // 服务端渲染时假定在线
);
}对照测试:连续挂载卸载 20 次并统计监听器的绑定/解绑次数,修复版两者完全相等,零残留。
顺便纠正一个很常见的误解:navigator.onLine 返回 true,只代表"设备连着某个网络",不代表能连上你的服务器。连着一个没通外网的酒店 WiFi,它照样是 true。
所以别拿它当离线模式的唯一开关。它适合做的是"网线拔了立刻提示用户",不是"判断能不能提交订单"——后者得自己打一个轻量心跳接口。
现象五:手机上点着别扭,下拉框根本选不上
罪魁祸首:useClickOutside
原版用 mousedown 监听。桌面端没问题,手机上会有明显的体感延迟:触屏点击会先触发 touchstart,再补一个模拟的 mousedown,中间隔着几百毫秒。配合弹层的关闭动画,用户会觉得"点了没反应,然后突然关掉了"。
换成 pointerdown 就能一次覆盖鼠标、触摸、触控笔三种输入。
还有个更隐蔽的。 判断"点在外面"靠的是 el.contains(target)。但如果被点的元素在事件处理时已经从 DOM 里移除了,这个判断会返回 false,于是被误判成点了外面。
典型场景:筛选面板里的已选标签,用户点标签上的 × 号想取消它,标签当场消失,整个筛选面板跟着一起关了。
加一句 target.isConnected 判断就能挡住——先确认这个元素还在文档里,再决定算不算"点在外面"。
tsimport { useEffect, useRef } from "react";
import type { RefObject } from "react";
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (event: PointerEvent) => void,
enabled = true
) {
// handler 存进 ref,调用方传内联箭头函数也不会反复重新订阅
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
if (!enabled) return;
const listener = (event: PointerEvent) => {
const el = ref.current;
const target = event.target as Node | null;
if (!el || !target) return;
if (el.contains(target)) return;
// 元素在点击过程中被移除了,不算"点在外面"
if (!target.isConnected) return;
savedHandler.current(event);
};
document.addEventListener("pointerdown", listener);
return () => document.removeEventListener("pointerdown", listener);
}, [ref, enabled]);
}多出来的 enabled 参数是给弹层关闭状态用的——面板都没打开,没必要在 document 上白挂一个监听。一个列表页二十个下拉菜单,这个差别是实打实的。
最后是标题里说的"下拉框选不上",这个必须单独提醒:这个 Hook 对 Portal 渲染的内容天然失效。
用 Ant Design 的 Select、DatePicker,或者 Radix 的任何弹层,下拉面板默认都挂在 document.body 上。DOM 结构上它根本不在你的 ref 里面,contains 必然返回 false——用户一点下拉选项,外层弹层就关了,选项永远选不上。
解法是给面板容器标个记号,listener 里放行:
ts// 在 listener 里加一行
if ((target as Element).closest?.("[data-ignore-outside]")) return;然后给 Ant Design 组件配 popupClassName 或 getPopupContainer,或者直接在自定义面板上写 data-ignore-outside。
现象六:复制按钮点了没反应
罪魁祸首:useCopyToClipboard
tsfunction useCopyToClipboard() {
const copy = (text) => navigator.clipboard.writeText(text);
return copy;
}三个问题叠在一起。
第一,最要命的是没有成功状态。 复制按钮的全部意义,就在于点完之后那句"已复制 ✓"。没有这个反馈,用户会怀疑自己没点上,然后连点五次。
中后台系统里到处都是复制按钮——API Key、订单号、邀请链接、服务器 IP——每一个都需要这个反馈。"点了没反应"这句用户投诉,八成不是没复制成功,是复制成功了但没告诉他。
第二,内网 HTTP 环境下直接崩。 navigator.clipboard 只在安全上下文(HTTPS 或 localhost)里存在。公司内网用 http://192.168.x.x 访问的运维后台、老管理系统,这行代码就是 Cannot read properties of undefined。这不是边缘场景——中后台项目里几乎是常态。
第三,writeText 返回的是 Promise。 用户拒绝剪贴板权限时它会 reject,没有 .catch 就是一个 unhandled rejection。
修好的版本补了三件事:copied 状态、HTTP 降级方案、异常兜底。
tsimport { useCallback, useEffect, useRef, useState } from "react";
export function useCopyToClipboard(resetAfter = 2000) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// 组件卸载时清掉待执行的重置定时器
useEffect(() => () => clearTimeout(timerRef.current), []);
const copy = useCallback(
async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// HTTP 环境降级:造一个隐藏输入框走老 API
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), resetAfter);
return true;
} catch {
setCopied(false);
return false;
}
},
[resetAfter]
);
return { copied, copy };
}调用方一行搞定按钮文案:
tsxconst { copied, copy } = useCopyToClipboard();
<button onClick={() => copy(apiKey)}>
{copied ? "已复制 ✓" : "复制"}
</button>document.execCommand("copy") 确实已经被标记为废弃了。但它至今在所有主流浏览器里都能正常工作,而且是 HTTP 环境下唯一的选择。留着,等真失效了再删——内网后台还得靠它。
现象七:深色模式首屏闪一下白
罪魁祸首:useDarkMode
原版是 document.body.classList.toggle("dark", enabled)。两个小问题。
类名加错地方了。 Tailwind 的深色模式默认看 <html> 上有没有 dark 类,加在 body 上不生效。
少了 color-scheme。 浏览器原生的滚动条、下拉菜单、输入框自动填充背景全都还是亮色——深色页面上挂一条白色滚动条,特别扎眼。
tsimport { useEffect } from "react";
export function useDarkMode(enabled: boolean) {
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("dark", enabled);
// 让浏览器原生控件(滚动条、表单)跟着一起变深
root.style.colorScheme = enabled ? "dark" : "light";
}, [enabled]);
}配合上面修好的 useLocalStorage:
tsxconst [dark, setDark] = useLocalStorage("theme-dark", false);
useDarkMode(dark);但闪白的问题这样还解决不了。
因为 React 得先下载、解析、执行完,才轮到 useEffect 去加类名。在那之前浏览器已经把白底画出来了。用户先看到白,然后突然变黑。
这个问题在 React 里解决不了,得赶在浏览器首次绘制之前就把类名定好——也就是在 HTML 里塞一段同步阻塞脚本:
html<script>
(function () {
try {
var saved = localStorage.getItem("theme-dark");
var dark = saved
? JSON.parse(saved)
: matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>Next.js 里放进 app/layout.tsx 的 <head>。这段小脚本是深色模式绕不过去的一步,去翻 Vercel、Tailwind 官网的源码,都能找到几乎一模一样的东西。
剩下四个:基本能用,但有点小刺
到这儿八个说完了。剩下四个问题都不大,一口气讲完。
useDebounce 原版就是对的,setTimeout 加 cleanup,教科书写法。
只补一句:如果你的场景是"输入框输入 → 过滤一个已经在内存里的大列表"(比如一万行表格的前端筛选),React 18 的 useDeferredValue 比防抖更合适——它会在有新输入时自动放弃过期的渲染,体感比固定 300ms 延迟更跟手。但如果防抖的目的是减少后端请求,那还得用 useDebounce,useDeferredValue 管不了这个。
useToggle 也没问题,硬要挑的话是 toggle 每次渲染都是新函数,传给 React.memo 包过的子组件会让 memo 白做。包一层 useCallback,顺手支持显式设值:
tsimport { useCallback, useState } from "react";
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback((next?: boolean) => {
setValue((prev) => (typeof next === "boolean" ? next : !prev));
}, []);
return [value, toggle] as const;
}那个 typeof next === "boolean" 不是啰嗦。要是图省事写成 next ?? !prev,onClick={toggle} 会把整个鼠标事件对象当成 next 传进来,布尔状态就变成了一个 SyntheticEvent,页面渲染出一片 [object Object]。这类 bug 排查成本很高——报错的位置往往离出问题的地方隔着好几层组件。
usePrevious 逻辑没毛病,就是 useRef() 不传参数在 React 19 的类型定义里不合法了,会报 Expected 1 arguments, but got 0。补个 undefined:
tsimport { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}它最好用的场景是"数值变化的视觉反馈":库存变了要闪一下(涨绿跌红)、销售额从旧值滚动到新值、价格降了冒个"↓"角标。
但要注意,它返回的是上一次提交时的值,不是"上一次渲染时的值"。做动画一般无所谓,可如果拿它做业务判断(比如"上一个状态是待支付才允许取消"),这个差别会咬人——那种场景老老实实把状态存进 state。
useDocumentTitle 本身也没毛病,就是从详情页返回列表页时标题不还原,标签页上一直挂着上一个订单号。加个可选开关:
tsimport { useEffect } from "react";
export function useDocumentTitle(title: string, restoreOnUnmount = false) {
useEffect(() => {
const previous = document.title;
document.title = title;
if (!restoreOnUnmount) return;
return () => {
document.title = previous;
};
}, [title, restoreOnUnmount]);
}用 Next.js App Router 的话其实用不上它,直接导出 metadata 更好——服务端就能把标题渲染进 HTML,对搜索引擎和分享卡片都友好。这个 Hook 更适合纯客户端渲染的中后台。
一张表带走
| 你遇到的现象 | 罪魁祸首 | 怎么改 |
|---|---|---|
| 选的是 A,显示的是 B | useFetch ❌ |
AbortController 取消旧请求 + 判 res.ok + 补 error 状态 |
| Toast 赖着不走、倒计时不结束 | useTimeout ❌ |
callback 存进 ref,别进依赖数组 |
| 页面越用越卡,内存下不去 | useOnlineStatus ❌ |
换 useSyncExternalStore |
| 复制按钮点了没反应 | useCopyToClipboard ❌ |
补 copied 状态 + HTTP 降级方案 |
| 本地好好的,一部署就白屏 | useLocalStorage ⚠️ |
挂载后再读;别拿它存 token |
| 拖窗口卡成 PPT(数据大屏) | useWindowSize ⚠️ |
useSyncExternalStore + 稳定快照 |
| 手机上点着别扭、下拉框选不上 | useClickOutside ⚠️ |
pointerdown + isConnected + Portal 兜底 |
| 深色模式首屏闪白 | useDarkMode ⚠️ |
挂 <html> + 首屏内联脚本 |
| React 19 下类型报错 | usePrevious ⚠️ |
useRef(undefined) |
| — | useDebounce ✅ |
纯前端过滤可考虑 useDeferredValue |
| — | useToggle ✅ |
加 useCallback |
| — | useDocumentTitle ✅ |
加卸载还原 |
抄之前,先问这三句
回头看,这九个坑虽然表现各异,但归纳下来就三类。所以从网上抄 Hook 之前,花两分钟过一遍:
一、服务端渲染会不会崩?
有没有在 useState 初始化里、或者组件函数体里,直接摸 window、document、navigator、localStorage。(现象三、现象四栽在这儿)
二、依赖数组里有没有每次渲染都变的东西? 函数、对象字面量、内联数组——它们每次都是新引用。(现象二栽在这儿)
三、异步任务和事件监听有没有清理干净?
AbortController、clearTimeout、removeEventListener,三样里少一样就是一个泄漏点。(现象一、现象四栽在这儿)
这三问能拦掉八成的问题。
抄是可以抄的。但得知道自己抄的是什么。
说说你的?
这次实测里最反直觉的是 useTimeout——对照组跑下来,原版写法在父组件持续重渲染的条件下,倒计时一次都没触发。而它在任何 demo 里都表现正常。
这类 bug 有个共同特征:本地复现不了,线上才出现。
那你呢——从网上复制过来、看着人畜无害、结果在线上坑了你一把的代码,是哪一段?
是某个依赖数组写漏导致的死循环,是某个"简化版"工具函数在边界情况下静默返回了错误结果,还是某段复制粘贴的正则在特定输入下直接把浏览器跑死了?
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

