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 制造机。一个聊天组件定时把当前草稿文本发送心跳:

tsx 复制代码
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

tsx 复制代码
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

几乎没有学习成本:

ts 复制代码
const stableFn = useEvent(fn);
  • fn ------ 任意函数。参数和返回值原样透传,this 也一样。
  • stableFn ------ TypeScript 类型与 fn 完全相同,但引用在组件生命周期内固定不变。

类型是精确的,不是 (...args: any[]) => any

tsx 复制代码
const format = useEvent((n: number, unit: string) => `${n}${unit}`);
format(3, 'px');   // ✅ string
format('3', 'px'); // ❌ 类型错误

开发环境下,如果传入的不是函数,会在控制台打印 useEvent expected parameter is a function, got ...,而不是悄悄失败。

内部实现原理

整个实现短到一杯咖啡就能读完,而且每一行都有它的道理:

ts 复制代码
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;
};

三个值得注意的细节:

  1. 用 ref 携带最新闭包。 每次渲染都产生一个捕获最新 state 的新 fn;effect 把它存进 handlerRef。返回的包装函数------用空依赖数组只 memoize 一次------在调用时 而非渲染时读取 handlerRef.current。外壳稳定,内核新鲜。

  2. ref 在 layout effect 里更新,而不是普通 effect。 useIsomorphicLayoutEffect 在 DOM 变更后同步执行,早于浏览器绘制、也早于被动的 useEffect 回调。如果 ref 在普通 useEffect 里更新,这个间隙中触发的任何事件------或同一次 commit 中更早运行的其他 effect------调用包装函数时就会命中上一次渲染的闭包。layout 时机堵上了这个窗口。

  3. 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

tsx 复制代码
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------任何仅仅因为闭包过期就要拆掉连接重建的地方,都很难堪:

tsx 复制代码
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 让回调保持新鲜而不重启定时器------它自己的实现就构建在 useEventuseLatest 之上。useTimeoutuseDebounceFnuseThrottleFn 也是同样的故事:过期闭包防护是内置的,所以在自己动手接 useEvent 之前,先看看你要造的 Hook 是不是已经存在了。

传给命令式组件的稳定回调

图表库、地图 SDK、编辑器通常在构造时接收处理函数:

tsx 复制代码
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 更新时机的直接推论:

  1. 不要在渲染期间调用返回的函数。 它属于事件处理、effect、定时器、回调------commit 之后才触发的东西。渲染期间,ref 可能还指向上一次的闭包。
  2. 不要用它对 effect 撒谎。 如果一个 effect 确实应该 在某个值变化时重新运行(比如筛选条件变化时重新请求数据),用 useEvent 包住逻辑来让 linter 闭嘴,等于埋掉了一个真实依赖。useEvent 是「读最新值、不重新触发」;它不是依赖数组的万能静音键。

要点回顾

  • useEvent 返回一个引用永久不变、闭包永远新鲜的函数 ------正是 useCallback 逼你二选一的那两样东西。
  • 技巧是一个在 layout effect 里更新的 ref ,加一个只 memoize 一次、调用时才读 ref 的包装函数。useIsomorphicLayoutEffect 保证 SSR 安全。
  • 渲染期间的值用 useCallback,事后触发的处理函数用 useEvent 永远不要在渲染期间调用 useEvent 的函数。
  • React 19.2+ 上,useEffectEvent 覆盖 effect 内部的场景 且有 linter 保驾;跨组件、跨库边界传递的稳定处理函数则交给 useEvent
  • 先查库里有没有现成的 ------useEventListeneruseIntervaluseDebounceFn 等等都已内置过期闭包防护。

@reactuses/core 获取它,退出依赖数组打地鼠游戏。

相关推荐
用户938515635071 小时前
从 DOM 编程到声明式 UI:React useRef 与 useState 底层全解析
前端·javascript·react.js
小月土星1 小时前
19.LeetCode 删除链表的倒数第 N 个节点 超详细题解(JS 版)
javascript
kisshyshy1 小时前
前端路由进化史:从刷新白屏到SPA,手写一个Hash路由就懂了!
前端·javascript·react.js
java1234_小锋2 小时前
Vue3专题 - 条件渲染
前端·javascript·vue.js
嘟嘟07172 小时前
从零理解 useRef:React 中操作 DOM 与持久化变量的唯一桥梁
javascript
名字还没想好☜3 小时前
React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理
前端·javascript·react.js·react·next.js
嘟嘟07173 小时前
摘要 手把手实现路由鉴权守卫,逐行拆解 ProtectRoute、localStorage、Navigate state、useLocation、FormDat
javascript
肉肉不吃 肉3 小时前
无渲染组件和组合式函数
前端·javascript·vue.js
其美杰布-富贵-李3 小时前
01 从项目结构开始认识 Vue 3
javascript·vue.js·ecmascript