2026年8月7日
React useEvent Hook:告别过期闭包的稳定回调 (2026)
每个 React 开发者迟早都会走到同一个岔路口。你写了一个读取 state 的事件处理函数,把它传给子组件或 effect,然后必须二选一:要么保持内联函数原样,看着每次渲染都产生一个新引用——React.memo 失效、effect 反复执行、监听器反复重新订阅;要么用 useCallback 包起来,开始玩依赖数组打地鼠——漏掉一个依赖,处理函数看到的就是三次渲染之前的 state。
第二种失败模式有个名字——过期闭包(stale closure)——它大概是生产代码里最常见的 React bug。解决方案也有个名字:useEvent,源自 2022 年的官方 React RFC,今天可以直接用 @reactuses/core 里的 useEvent。它给你一个引用在渲染之间永不变化、但函数体总能读到最新 state 和 props 的函数。岔路口的两边,全都要。
本文涵盖它的 API、让这一切成立的三行实现技巧、与 useCallback 及 React 19.2 内置 useEffectEvent 的对比、真实使用模式,以及必须遵守的一条规则(不要在渲染期间调用它)。TypeScript 优先。
三十秒看懂问题
这就是 bug 制造机。一个聊天组件定时把当前草稿文本发送心跳:
function Composer({ roomId }: { roomId: string }) {
const [draft, setDraft] = useState('');
useEffect(() => {
const id = setInterval(() => {
sendHeartbeat(roomId, draft); // ⚠️ 哪个 draft?
}, 3000);
return () => clearInterval(id);
}, [roomId]); // 故意省略 draft——我们不想重置定时器
return <textarea value={draft} onChange={e => setDraft(e.target.value)} />;
}
interval 闭包捕获的是 effect 运行时的那个 draft——空字符串。此后每次心跳发的都是 ''。把 draft 加进依赖数组,闭包倒是新鲜了,但定时器会在每次按键时销毁重建。useCallback 帮不上忙:它有一模一样的依赖数组,所以逼你做一模一样的选择——要么值过期,要么引用不停变。
你真正想要的,是一个在组件生命周期内始终是同一个东西、但触发时读到当前值的函数。这就是 useEvent:
import { useEvent } from '@reactuses/core';
function Composer({ roomId }: { roomId: string }) {
const [draft, setDraft] = useState('');
const beat = useEvent(() => {
sendHeartbeat(roomId, draft); // ✅ 永远是最新的 draft 和 roomId
});
useEffect(() => {
const id = setInterval(beat, 3000);
return () => clearInterval(id);
}, [beat]); // beat 永不变化——effect 只运行一次
return <textarea value={draft} onChange={e => setDraft(e.target.value)} />;
}
beat 在每次渲染中引用完全相同,所以 effect 只运行一次,定时器在打字过程中稳如泰山。当它触发时,通过最新一次渲染的闭包读取 draft。依赖数组甚至是诚实的——beat 列在里面,只是它恰好稳定。
完整 API
几乎没有学习成本:
const stableFn = useEvent(fn);
fn—— 任意函数。参数和返回值原样透传,this也一样。stableFn—— TypeScript 类型与fn完全相同,但引用在组件生命周期内固定不变。
类型是精确的,不是 (...args: any[]) => any:
const format = useEvent((n: number, unit: string) => `${n}${unit}`);
format(3, 'px'); // ✅ string
format('3', 'px'); // ❌ 类型错误
开发环境下,如果传入的不是函数,会在控制台打印 useEvent expected parameter is a function, got …,而不是悄悄失败。
内部实现原理
整个实现短到一杯咖啡就能读完,而且每一行都有它的道理:
export const useEvent = <T extends Fn>(fn: T) => {
const handlerRef = useRef(fn);
useIsomorphicLayoutEffect(() => {
handlerRef.current = fn;
}, [fn]);
return useCallback((...args) => {
const fn = handlerRef.current;
return fn(...args);
}, []) as T;
};
三个值得注意的细节:
-
用 ref 携带最新闭包。 每次渲染都产生一个捕获最新 state 的新
fn;effect 把它存进handlerRef。返回的包装函数——用空依赖数组只 memoize 一次——在调用时而非渲染时读取handlerRef.current。外壳稳定,内核新鲜。 -
ref 在 layout effect 里更新,而不是普通 effect。
useIsomorphicLayoutEffect在 DOM 变更后同步执行,早于浏览器绘制、也早于被动的useEffect回调。如果 ref 在普通useEffect里更新,这个间隙中触发的任何事件——或同一次 commit 中更早运行的其他 effect——调用包装函数时就会命中上一次渲染的闭包。layout 时机堵上了这个窗口。 -
Isomorphic 意味着 SSR 安全。 服务端使用
useLayoutEffect会打印 hydration 警告;useIsomorphicLayoutEffect在 SSR 时换成useEffect,浏览器里再换回真家伙。没有警告,你的代码也不用做特殊处理。
如果这个 ref 持有技巧看着眼熟,那是因为它和 useLatest 是同一个思路——useEvent 本质上就是 useLatest 加一个稳定的可调用外壳。想在某个现有回调里读取最新值时用 useLatest;当回调本身就是你要到处传递的东西时用 useEvent。
useEvent vs useCallback
它们解决的是不同的问题,对比一下两者都更清楚:
useCallback | useEvent | |
|---|---|---|
| 引用 | 依赖变化就变 | 永不变化 |
| 闭包新鲜度 | 取决于依赖数组写得对不对 | 总是最新——调用时才读取 |
| 依赖数组 | 必需;bug 的温床 | 无 |
| 渲染期间可调用? | ✅ 可以 | ❌ 不行——仅限事件/effect 时机 |
| 适合 | 渲染期间计算的值(memoized selector、render prop) | 事后触发的处理函数(事件、定时器、订阅) |
「渲染期间」这一行是真正的分界线。useCallback 的结果是个普通值——你可以在渲染时调用它来计算 JSX。useEvent 的包装函数读取的 ref 只保证在 commit 之后是最新的,渲染期间调用可能观察到上一次渲染的 state(也违反了 RFC 谨慎设计的并发渲染契约)。经验法则不言自明:如果函数是响应某件事而触发的——点击、定时、消息——用 useEvent;如果它在渲染期间计算东西——用 useCallback。
useEvent vs React 官方的 useEffectEvent
2022 年的 RFC 最终被取代:React 把这个想法以 useEffectEvent 的形式发布,自 React 19.2 起稳定。如果你在 19.2+ 上,应该了解两者的关系:
useEffectEvent刻意收窄了范围。 返回的函数只能在 effect 内部调用(ESLint 规则强制执行),也不能传给其他组件或 Hook。React 团队把它限定在他们认为万无一失的那一种模式:在 effect 里读最新值而不重新触发 effect。useEvent覆盖更宽的场景。 把稳定的处理函数传给 memoized 子组件、命令式组件、WebSocket 封装或第三方 SDK——这些useEffectEvent的 linter 全都会拒绝——恰恰是用户态useEvent的用武之地。代价是更宽的场景包含了上面那个渲染期调用的坑,纪律从 linter 转移到了你身上。- 两者可以共存。 React 19.2+ 上 effect 内部用
useEffectEvent,跨组件边界的稳定引用用useEvent;19.2 以下则全部用useEvent——那里根本没有useEffectEvent。
使用模式
不破坏 React.memo 的处理函数 prop
经典的列表行场景——memoized 的行组件照样重渲染,因为父组件每次渲染都重新创建 onSelect:
const Row = React.memo(function Row({ item, onSelect }: RowProps) {
return (
<li onClick={() => onSelect(item.id)} className="row">
{item.label}
</li>
);
});
function List({ items }: { items: Item[] }) {
const [selected, setSelected] = useState<string[]>([]);
const handleSelect = useEvent((id: string) => {
// 读到最新的 selected,没有依赖数组要维护
setSelected(selected.includes(id)
? selected.filter(s => s !== id)
: [...selected, id]);
});
return (
<ul>
{items.map(item => (
<Row key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
handleSelect 每次渲染都是同一个引用,React.memo 才真正 memo 得起来。换 useCallback 的话,你要么把 selected 列进依赖(引用变来变去,memo 白费),要么到处用函数式更新(这里还行,一旦处理函数要读两份 state 就没辙了)。
永不重新订阅的订阅
WebSocket、EventSource、SDK——任何仅仅因为闭包过期就要拆掉连接重建的地方,都很难堪:
function usePriceFeed(symbol: string, threshold: number) {
const [price, setPrice] = useState(0);
const onMessage = useEvent((e: MessageEvent) => {
const next = JSON.parse(e.data).price as number;
setPrice(next);
if (next > threshold) notify(symbol, next); // 永远是最新的 threshold
});
useEffect(() => {
const ws = new WebSocket(`wss://feed.example.com/${symbol}`);
ws.addEventListener('message', onMessage);
return () => ws.close();
}, [symbol, onMessage]); // 只有 symbol 变化才重连
return price;
}
socket 在 symbol 变化时重连——这是正当理由——而 threshold 变化时绝不重连。注意:对于普通 DOM 目标,useEventListener 内部已经做了这件事(它用 useLatest 包住你的 handler),所以只有当订阅管道由你自己掌管时才需要 useEvent。
定时器——或者直接用库里现成的
上面的心跳例子太常见了,@reactuses/core 直接内置了解法:useInterval 让回调保持新鲜而不重启定时器——它自己的实现就构建在 useEvent 和 useLatest 之上。useTimeout、useDebounceFn、useThrottleFn 也是同样的故事:过期闭包防护是内置的,所以在自己动手接 useEvent 之前,先看看你要造的 Hook 是不是已经存在了。
传给命令式组件的稳定回调
图表库、地图 SDK、编辑器通常在构造时接收处理函数:
function Editor({ docId }: { docId: string }) {
const [dirty, setDirty] = useState(false);
const handleSave = useEvent((content: string) => {
saveDocument(docId, content); // 最新的 docId
setDirty(false);
});
useEffect(() => {
const editor = createEditor('#mount', { onSave: handleSave });
return () => editor.destroy();
}, [handleSave]); // 稳定 → 编辑器只创建一次
return <div id="mount" data-dirty={dirty} />;
}
仅仅因为 docId 在闭包里换了个引用就重建一个重量级编辑器——这正是 useEvent 要消灭的浪费。
两条规则
只有两条,都是 ref 更新时机的直接推论:
- 不要在渲染期间调用返回的函数。 它属于事件处理、effect、定时器、回调——commit 之后才触发的东西。渲染期间,ref 可能还指向上一次的闭包。
- 不要用它对 effect 撒谎。 如果一个 effect 确实应该在某个值变化时重新运行(比如筛选条件变化时重新请求数据),用
useEvent包住逻辑来让 linter 闭嘴,等于埋掉了一个真实依赖。useEvent是「读最新值、不重新触发」;它不是依赖数组的万能静音键。
要点回顾
useEvent返回一个引用永久不变、闭包永远新鲜的函数——正是useCallback逼你二选一的那两样东西。- 技巧是一个在 layout effect 里更新的 ref,加一个只 memoize 一次、调用时才读 ref 的包装函数。
useIsomorphicLayoutEffect保证 SSR 安全。 - 渲染期间的值用
useCallback,事后触发的处理函数用useEvent。 永远不要在渲染期间调用useEvent的函数。 - React 19.2+ 上,
useEffectEvent覆盖 effect 内部的场景且有 linter 保驾;跨组件、跨库边界传递的稳定处理函数则交给useEvent。 - 先查库里有没有现成的——
useEventListener、useInterval、useDebounceFn等等都已内置过期闭包防护。
从 @reactuses/core 获取它,退出依赖数组打地鼠游戏。