跳到主要内容

useEffectEvent:把"非响应式逻辑"从 Effect 里抽出来

· 阅读需 20 分钟

在 React 里,你迟早会遇到这么个两头堵的问题:Effect 里要读某个最新的 props / state,但又不想它一变就让 Effect 重跑。加进依赖数组吧,副作用会过度执行(反复重连、反复重置定时器);不加吧,闭包读到的永远是"过期的旧值",而且 ESLint 还会红字报警。

React 19.2(2025 年 10 月转正)带来了专门解决这个问题的 useEffectEvent。但要真正理解它为什么长这样、限制为什么那么多,得从 React 最底层的心智模型——不可变(Immutable)快照——说起。


1. 从 Immutable 说起:每次渲染都是一份"照片"

React 的 props 和 state 是不可变的。这句话的意思是:更新 state 不是"把旧值改掉",而是丢弃旧快照、生成一个新快照、然后带着新快照重新渲染一次

这个模型带来一个极其关键的推论:组件体里的每一个闭包,捕获的都是"这一次渲染"的快照值。

function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
const id = setTimeout(() => {
console.log(count); // 打印的是"创建 effect 时"的 count,不是最新的
}, 1000);
return () => clearTimeout(id);
}, []);

return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

上面这个例子里,不管点多少次按钮,setTimeout 回调里打印的 count 都是第一次渲染时的值(0)。因为 useEffect 的回调是在"渲染 #1"里创建的,它捕获的 count 就是渲染 #1 的快照——之后 state 变了,组件重新渲染,但旧闭包不会"自动看到"新值。

对比 Vue 的响应式模型会更容易看清这个差异:Vue 的 ref / reactive可变的代理,effect 里读 .value 永远是"运行时的当前值"(依赖收集 + 追踪);而 React 的值在渲染那一刻就是定死的,不存在"活的引用"这种东西。

所以 React 里"在 effect 里读最新值"天然是个难题——这就是 useEffectEvent 存在的全部理由。


2. Effect 的两难:重跑 vs 陈旧

问题的本质是:Effect 有两根互相独立的轴——

  1. 执行节奏:什么时候该重跑?(由依赖数组决定)
  2. 读取的值:执行时读到的 props / state 应该多新?(由闭包决定)

一个典型的例子——聊天室连接。需求是:只有切换房间时才重连,但连接成功后的通知回调里要用到"最新的主题色":

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', theme); // ← 想在回调里读最新的 theme
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // 加 theme:换主题也会重连
}

theme 放进依赖数组,语义上没问题,代价是换个主题也要断开重连——连接对象重建、可能丢消息、体验很差。

那不放 theme 呢?回调闭包捕获的就是旧 theme,用户切了深色主题,弹的通知还是浅色样式。而且 eslint-plugin-react-hooks 会直接报警:"React Hook useEffect has a missing dependency: 'theme'"。

加依赖 → 过度重跑;删依赖 → 读到旧值。 这正是社区里无数人被迫 // eslint-disable-next-line react-hooks/exhaustive-deps 的原因,而这一行注释往往掩盖了真正的 bug。


3. 现有的"绕法"及代价

useEffectEvent 之前,工程师们想了各种办法绕过这个两难,但每一种都各有代价:

绕法一:用 useRef 手动同步"最新值"

ref 是 React 里唯一"可变、且不触发重渲染"的容器。于是有人用 ref 当"最新值的中转站":

function ChatRoom({ roomId, theme }) {
const themeRef = useRef(theme);
useEffect(() => { themeRef.current = theme; }, [theme]); // 手动同步

useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', themeRef.current); // 读的是"最新"主题
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}

能跑,但痛点很明显:每次要"读最新值",都得先声明一个 ref,再写一个专门同步它的 effect。组件里到处是这种 xxxRef.current,很容易漏同步、忘清理,读代码的人也得多绕一层"它到底是不是最新的"。

绕法二:把"要读最新值的逻辑"整体挪进事件处理器

有些场景确实可以——比如把逻辑从 effect 挪到 onClick 里。但订阅外部系统(socket / interval / 浏览器 API)这件事只能从 effect 发起,事件处理器够不着。这条路在很多场景根本走不通。

绕法三:useCallback / useMemo 包一层

useCallback 负责"函数身份稳定",但它解决不了"读到最新值"——useCallback 的闭包同样捕获它创建时的值。它和这个问题不是一个维度,后面章节的对比表会再讲。

绕法能否读到最新值代价
useRef 手动同步样板代码多、容易漏同步,心智负担重
挪进事件处理器✅(但不总能挪)订阅类副作用只能从 effect 发起,场景受限
useCallback / useMemo解决的是"身份稳定",不是"读到最新"

这些绕法共同的问题是:你被迫把"响应式值"和"读它的逻辑"人工拆散、再手工缝合useEffectEvent 把这个过程变成了语言特性。


4. useEffectEvent 登场:给 Effect 配一个"非响应式"回调

useEffectEvent 的用法非常简单:

const onConnected = useEffectEvent(() => {
// 这里随便读 props / state,读到的永远是"最新"的
showNotification('Connected!', theme);
});

它接收一个函数,返回一个 Effect Event(Effect 事件)。这个事件函数有三个关键特性:

  1. 永远读到最新值:调用时读到的 props / state 是"当前"的,不陈旧;
  2. 不触发重跑:它内部读的值发生变化,不会导致所属 effect 重跑;
  3. 不进依赖数组:Effect Event 故意被排除在依赖数组之外,eslint-plugin-react-hooks v6.1.x 之后会正确识别它,不会再报警。

改造聊天室例子:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // theme 读最新,但不触发重连
});

useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 依赖干干净净,只有 roomId
}

依赖数组里只有 roomId换房间才重连,而通知回调永远用最新主题。两个问题同时解决。

需要"响应式"的数据,作为参数传进去

Effect Event 不是把一切变成非响应式——如果你想对某个值保持响应式(它变化时要重跑 effect),那就把它放进依赖数组,然后作为参数传给 Effect Event:

const onConnected = useEffectEvent((to: string) => {
// theme 非响应式(读最新),to 是调用方传入的响应式值
showNotification(`Connected to ${to}`, theme);
});

useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected(roomId)); // roomId 作为参数
connection.connect();
return () => connection.disconnect();
}, [roomId]);

一句话原则:"决定何时重跑"的值放依赖数组,并作为参数传入;"只读一次最新值"的值留在 Effect Event 的闭包里。


5. 规则与限制:为什么限制这么多

useEffectEvent 可能是 React 里限制最多的一个 Hook,官方文档明确列出:

  1. 只能在 Effect 里调用:它只能在 useEffectuseLayoutEffectuseInsertionEffect 的回调(或另一个 Effect Event)里调用。在事件处理器里调用它没有意义(事件处理器本来就活在"最新"的世界里)。
  2. 不要把它传给别的组件 / Hook:它必须和"自己的 Effect"声明在同一个组件里,紧挨着使用它的那个 effect。传下去没有意义,还会破坏 lint 检查(eslint-plugin-react-hooks 6.1.1+ 会在内联成 prop 传下去时直接报错)。
  3. 不能用来逃避依赖数组:如果把"本应放在依赖数组里的响应式值"全塞进 Effect Event,等于告诉 React"别管它",会掩盖真实 bug。它只服务于真的不该让 effect 重跑的逻辑。
  4. 不能在渲染期间调用:渲染期调用会直接抛错 "A function wrapped in useEffectEvent can't be called during rendering."
  5. 不要放进依赖数组:它身份本就不稳定(内部每次渲染返回新闭包),放进依赖数组会让 effect 每帧重跑——这其实是 React 故意设计的"运行时断言":你要是用错了,bug 会立刻暴露,而不是悄悄坏掉。

为什么这么苛刻?

因为 React 团队明确:Effect Event 不是一个"逃避响应性的通用工具",它是某个特定 Effect 的"私有回调"。效果事件在概念上隶属于一个 Effect,它的存在就是为了让这个 Effect 既能"按需重跑",又能在执行时"读到最新值"——这两个能力被设计成只有组合在一起才有意义,所以不给你把它当普通函数传来传去的自由度。限制恰恰是设计意图的一部分。

内部实现上,useEffectEvent 返回的函数每次渲染都是一个新的闭包,React 会在 commit 阶段(useLayoutEffect 之前)把它更新到 fiber 上——effect 调用时真正执行的是"最新那份"。它之所以不放进依赖数组,也正因为身份不稳定本身就是用错时的显式报错。


6. useEffectEvent 的底层原理:一个"延迟到 commit 的槽位"

useEffectEvent 的威力,其实建立在 React 一个非常朴素的机制上:用一个对象当"槽位"存回调,再让 effect 拿到的函数永远从槽位里取最新值。React 官方文档把它概括成一句话:"它把函数存进一个 ref,并在运行任何 effect 之前更新这个 ref。"

源码(ReactFiberHooks.js)里就是三步:

① mount 时:创建槽位 + 返回"门面函数"

// ReactFiberHooks.js(简化)
function mountEvent(callback) {
const hook = mountWorkInProgressHook();
const ref = { impl: callback }; // 槽位:一个对象,装着回调
hook.memoizedState = ref;
return function eventFn() { // 门面:调用时从槽位取"最新回调"执行
if (isInvalidExecutionContextForEventFunction()) {
throw new Error("A function wrapped in useEffectEvent can't be called during rendering.");
}
return ref.impl.apply(undefined, arguments);
};
}

关键在 ref:它是一个对象。门面闭包捕获的是这个对象的引用,而不是某个具体回调的值——所以之后槽位里换了哪个回调,门面函数读到的永远是"最新的那个"。

② update 时:不立刻替换,先记账

function updateEvent(callback) {
const hook = updateWorkInProgressHook();
const ref = hook.memoizedState; // 复用同一个槽位(同一个对象)
// 不直接 ref.impl = callback,而是先记录,commit 阶段统一结算
const queue = currentlyRenderingFiber.updateQueue;
(queue.events ??= []).push({ ref, nextImpl: callback });
return function eventFn() { /* 同 mount,读 ref.impl */ };
}

注意这里没有在渲染阶段就把 ref.impl 换掉,而是把 { ref, nextImpl } 塞进 fiber 的 updateQueue.events 数组,等 commit 阶段一起处理。这能避开一个并发陷阱:渲染随时可能被打断、整棵 workInProgress 树被丢弃重来——如果渲染阶段就直接改了 ref,这次渲染被放弃时 ref 就"白改了",留下一个和最终提交的树不一致的槽位。延迟到 commit 阶段结算,才能保证槽位与最终提交的那棵树原子地同步。

③ commit 时:在 effect 运行前结算

// commitBeforeMutationEffectsOnFiber(在 useLayoutEffect 之前执行)
function commitUseEffectEventMount(finishedWork) {
for (const { ref, nextImpl } of finishedWork.updateQueue.events) {
ref.impl = nextImpl; // 结算:槽位换成最新回调
}
}

这个结算发生在 commit 的 beforeMutation 子阶段——早于 useLayoutEffect,更早于 useEffect(passive effect)。所以当你的 effect 回调真正执行时,槽位里已经是最新版本的函数了。

最后还有一个细节:返回的门面函数身份故意不稳定——每次渲染都返回一个新闭包。因为 React 希望"如果你把效果事件放进依赖数组,bug 立刻炸出来"(effect 每帧重跑),而不是悄悄失效。这是一句"运行时断言",也是它不能被当作 useCallback 替代品的原因。


7. 自己实现一个 useEffectEvent(自定义 Hook)

既然底层就是"一个 ref 槽位 + 在 effect 运行前更新",那用 React 现成的 Hook 完全可以拼出行为等价的自定义版本。这也是社区 ponyfill(use-effect-event)的做法:

import { useInsertionEffect, useRef, useCallback } from 'react';

/**
* 自定义 useEffectEvent:
* 把最新回调存进 ref,在 commit 阶段(effect 运行前)更新槽位,
* 返回一个稳定的门面函数,调用时永远执行最新回调。
*/
function useEffectEvent<Args extends unknown[], R>(
fn: (...args: Args) => R,
): (...args: Args) => R {
const ref = useRef(fn);
// useInsertionEffect 在 commit 的 mutation 阶段同步执行,
// 先于 useLayoutEffect 和 useEffect —— 与内置版的时机一致
useInsertionEffect(() => {
ref.current = fn;
}, [fn]);

// 稳定的门面:永远从 ref 里取"最新"回调
return useCallback((...args: Args) => ref.current(...args), []);
}

用起来和内置版一模一样:

const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // 读最新 theme,但不触发重连
});

useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 依赖数组不需要(也不能)包含 onConnected

为什么用 useInsertionEffect,而不是 useEffect 来同步?

因为时序。内置版在 commit 的 beforeMutation 阶段更新槽位,先于一切 effect;useInsertionEffect 也在 commit 的 mutation 阶段、layout 之前同步执行——两者都保证了"effect 开始前,槽位已更新"。如果用 useEffect 去同步 ref,它属于 passive effect,排在 useLayoutEffect 之后才执行:layout effect 里调用门面时读到的是上一轮的回调,时序就错了。这也正是早期"useRef + 手工同步"绕法最容易踩的坑——很多人用 useEffect 同步 ref,却不知道 layout 阶段已经把旧值读走了。

与内置版的三个差异(诚实版)

内置 useEffectEvent自定义版本
返回的函数身份刻意不稳定(每次渲染新闭包)稳定(useCallback([])
渲染期调用守卫抛错 "can't be called during rendering"❌ 无(用户层无法可靠判断渲染期,靠 eslint 兜底)
批量更新多个效果事件共用一个 events 数组,一次遍历结算每个 hook 各自更新自己的 ref

其中"身份不稳定"这点在语义上其实无所谓——因为效果事件本来就不允许进依赖数组,函数身份是否稳定不影响任何行为;差异①只是为了"用错时显式报错"。所以这个自定义版本在日常使用中和内置版等价,唯一实质区别是少了渲染期调用守卫(生产中主要靠 eslint-plugin-react-hooks v6.1+ 的规则来拦)。

如果想连"刻意不稳定"也复刻,去掉 useCallback,直接返回一个内联函数即可:

return (...args: Args) => ref.current(...args); // 每次渲染都是新函数,读最新回调

8. 相关 Hook 横向对比

useEffectEvent 经常和下面这几个 Hook 混淆,放一起看最清楚:

Hook读最新值?触发重跑?身份稳定?职责
useEffect❌(闭包捕获快照)✅ 依赖变化才跑声明副作用
useRef✅(ref.current 可变)可变容器,不触发渲染
useCallback❌(同闭包问题)缓存函数身份
useMemo缓存计算结果
useLayoutEffect同步副作用,在浏览器绘制前执行
useInsertionEffect在 DOM 变更前同步执行(常用于写样式)
useEffectEvent✅(调用时永远最新)❌(读的值变了不重跑)❌(刻意不稳定)把 effect 里的非响应式逻辑抽出来

一个容易搞混的点再强调一遍:useCallback 是"缓存函数身份",useEffectEvent 是"读到最新值"——方向完全不同。useCallback(() => fn(...), [x])x 不变时返回同一个函数,但那个函数捕获的是创建时的快照;useEffectEvent 返回的函数身份不稳定,但调用时执行的是最新逻辑。


9. 实战场景

场景 1:聊天室连接(订阅类副作用,最典型)

function ChatRoom({ roomId, theme, username }) {
const onConnected = useEffectEvent(() => {
showNotification(`欢迎 ${username} 进入房间`, theme);
});

useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // 只有换房间才重连
}

场景 2:页面埋点 / 日志上报

需求:URL 变化时上报一次访问,但要带最新的用户信息和套餐等级:

function PageTracker({ url }) {
const { userId, plan } = useUser(); // 用户信息会变,但不需要因此重新上报

const reportVisit = useEffectEvent(() => {
analytics.track('page_view', { url, userId, plan }); // 上报时读最新值
});

useEffect(() => {
reportVisit();
}, [url]); // 只有 URL 变化才上报
}

如果用老办法(把 userId/plan 全放进依赖),每次用户信息更新都会多上报一次;不放进依赖则上报的永远是旧用户。useEffectEvent 两个问题一起消掉。

场景 3:轮询

需求:定时器只启动一次,但每次拉数据时都要带"最新"的筛选条件:

function useOrderPolling(filters) {
const [orders, setOrders] = useState([]);

const fetchLatest = useEffectEvent(async () => {
const res = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(filters), // 每次轮询都读最新的 filters
});
setOrders(await res.json());
});

useEffect(() => {
const id = setInterval(() => {
fetchLatest();
}, 5000);
return () => clearInterval(id);
}, []); // 定时器不随 filters 重建
}

filters 变了,定时器不重启,但下一次轮询已经用上最新条件。

场景 4:防抖保存(同时演示"参数 + 闭包"两种取值)

function Editor() {
const [content, setContent] = useState('');
const { version } = useDocVersion(); // 版本号,变了不该打断防抖

const flush = useEffectEvent((text: string) => {
saveToServer(text, version); // version 读最新;text 是响应式参数
});

useEffect(() => {
if (!content.trim()) return;
const id = setTimeout(() => flush(content), 500);
return () => clearTimeout(id);
}, [content]); // 输入变化才重启防抖计时器
}

这里两套取值方式都用到了:content 是"决定何时重跑"的响应式值,放进依赖数组并作为参数传入;version 是"只要读最新就行"的值,留在 Effect Event 闭包里。


10. 现状:从 useEvent 到 React 19.2 转正

useEffectEvent 不是 19.2 才发明的,它走了一条相当长的路:

时间事件
2022-05Dan Abramov 提出 useEvent RFC,实验性实现进入 @experimental 版本
2022-09原 RFC 关闭——React 团队担心它会把"渲染优化"和"修 Effect"两件事捆在一起,容易诱导开发者滥用;决定收窄范围
2022-12改名 useEffectEvent(PR #25881),只解决"把事件从 Effect 里分离",渲染优化交给未来的编译器
2023-03React Labs 更新中正式对外介绍
2023–2025一直以实验性(experimental_useEffectEvent)存在于 canary
2025-10-01React 19.2 发布,useEffectEvent 正式转正为稳定 API(同批还有 <Activity>cacheSignal 等)

落地要点

  • 版本:React 19.2+,直接 import { useEffectEvent } from 'react'
  • ESLint:必须升级到 eslint-plugin-react-hooks v6.1.x,否则 linter 不认识这个 Hook,会把效果事件当作缺失依赖反复报警;
  • 老版本怎么办:可以用社区 ponyfill 包 use-effect-event;想自己动手,参考第 7 节的自定义实现(useRef + useInsertionEffect + useCallback),行为与内置版等价。

一点讨论

社区里对 useEffectEvent 的评价并不全是掌声,有人吐槽它是"给 React 自己制造的问题再补的补丁"。React 团队则回应:任何响应式模型都需要一个"逃逸舱"(类比其他框架的 untrack)——不可能让所有 effect 逻辑都无脑响应所有读取的值。相比"删依赖 + eslint-disable",useEffectEvent 把"故意不响应"从注释升级成了语法上可检查、语义上明确的语言特性。这也是它规则虽多、却比绕法更可靠的原因:限制越多,越不可能用错。


11. 一句话总结

React 的 props / state 是不可变的——每次渲染都是一份快照,Effect 闭包捕获的永远是"这次渲染"的值。想让 Effect 在"不重跑"的同时"读到最新值",过去的绕法(useRef 手动同步、删依赖加注释)各有代价。useEffectEvent 把 effect 里的非响应式逻辑抽成一个 Effect Event:调用时永远读最新 props / state、读的值变了不会触发重跑、也无需进依赖数组。代价是限制极多(只能在 Effect 里调用、不能传出组件、不能用于逃避依赖数组),但这些限制恰恰保证它不会被用错——它是"给 effect 配一个永远新鲜的助手",不是"让一切不再响应"的万能开关。


参考