译注 :本文翻译自 LogRocket 博客,原文作者 Jack Herrington,发布于 2025 年 1 月 7 日。 原文链接:React has finally solved its biggest problem: The joys of useEffectEvent
React 终于解决了它最大的问题:useEffectEvent 的妙用
如果问你 React 最大的 bug 来源是什么,你会说什么?大多数人都会说 useEffect。这个名字很奇怪的 Hook 允许你执行异步工作,这很好,但也会导致很多问题。特别是无限循环------我们一直在从服务器获取数据时遇到这个问题。
React 团队看到了这个问题,他们想出了一个新的 Hook 叫 useEffectEvent。我知道这个名字很绕口,但当涉及到稳定你的 React 应用时,它是一个救命稻草。
让我带你经历一个非常常见的问题。我们将从今天拥有的 Hooks 开始,这样我们可以看到问题,然后我将展示 useEffectEvent 如何修复它。
为什么这很重要:Cloudflare 的 useEffect 事故
Cloudflare 是全球最大的部署提供商之一,他们有一个优秀的工程团队。但即使他们在 useEffect 方面也犯了错误。之前,他们在错误地将一个对象放入依赖数组时,等于对自己的仪表板发起了分布式拒绝服务攻击(DDoS)。该对象在每次重新渲染时改变其引用标识,导致无限循环,搞垮了整个仪表板。
这是一个尴尬的错误,但太容易犯了。这就是为什么 React 编译器和 useEffectEvent 这样的新 Hooks 如此重要。在编译器的情况下,它们稳定对象引用,有助于减少围绕对象标识的潜在 bug。useEffectEvent 则完全从依赖数组中移除对象!
搭建场景
这是一个具有可编辑用户名的简单组件:
javascript
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");
return (
<div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
到目前为止一切顺利;我们可以更改用户名。现在假设我们想要追踪用户已登录多长时间,然后显示它:
javascript
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
我们添加了一个 useEffect,在组件挂载时设置一个计时器来追踪此人登录的秒数。(是的,我知道这不是真正的登录;这是演示代码。)
现在这段代码实际上运行正常,没有 bug。它仅在组件挂载时运行一次,因为有一个空的依赖数组。它通过返回一个清理函数来清理自己,该函数清除间隔,从而杀死计时器。
但在功能方面,它实际上不起作用,因为我们没有在任何地方显示那个数字。为了修复这个问题,让我们添加一个 loginMessage 字符串,我们可以用它来显示那个数字:
javascript
function MyUserInfo() {
const [userName, setUserName] = useState("Bob");
const [loginMessage, setLoginMessage] = useState("");
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${userName} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<div>{loginMessage}</div>
<input
value={userName}
onChange={(evt) => setUserName(evt.target.value)}
/>
</div>
);
}
现在这段代码看起来应该可以工作。实际上,它多少有点有效。开始时,它说"Bob 已登录 1 秒"。然后它每秒忠实地往前走。巨大的成功!
过期闭包问题
哎呀,实际上有一个 bug。因为我们发送给 useEffect 的函数可能会"过期"。
如果我更改用户名会发生什么?好的,input 会改变,但登录消息仍然说用户名是"Bob"。但不是;我们改变了它。
所以我们发送给 useEffect 的函数已经创建了一个"闭包",它在当时以当前值("Bob")捕获了 userName 的值。它永远不会改变。因为它现在与实际值不同步,我们会认为它是"过期的"。那意味着我们有一个"过期闭包"(stale closure)。
好消息是,React 对此有一个修复(这不是 useEffectEvent,请耐心等待)。我们可以将 userName 添加到依赖数组中:
javascript
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${userName} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, [userName]);
成功了!现在当我们编辑 userName 时,登录消息会改变!太棒了。哦,等等。什么?每次我们进行更改时,登录时间都会重新开始为 1 秒。
啊,所以每次我们创建一个带有新 userName 值的新闭包时,我们都会杀死旧计时器(这很好)。但我们也创建了一个新的 loggedInTime 并再次从零开始。这绝对不好。
我明白一个简单的修复方法是追踪 loggedInTime 状态并在 JSX 中格式化字符串。好的。但让我们假设我们做不到。
useRef 来救援
我们怎样才能解决这个问题?好吧,在 useEffectEvent 之前,我们可能会使用 ref:
javascript
const nameRef = useRef(userName);
nameRef.current = userName;
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${nameRef.current} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);
这里我们做了几件事。首先,我们创建了一个 ref,其中存储了 userName 的当前值,我们在每次渲染时更新当前值。在渲染期间设置 ref 的 current 值是可以的,因为 React 不像监控状态那样监控 refs。
接下来,我们在模板字符串中使用 nameRef.current 而不是 userName,所以我们总是获得 userName 的当前值,因为它在每次渲染时更新。最后,我们从依赖数组中移除了 userName,这解决了重置 bug。
现在它实际上有效。没有注意事项!除了,它有点笨重,这就是 useEffectEvent 的用武之地。
useEffectEvent 好得多
看看这个版本:
javascript
const getName = useEffectEvent(() => userName);
useEffect(() => {
let loggedInTime = 0;
const interval = setInterval(() => {
loggedInTime++;
setLoginMessage(
`${getName()} has been logged in for ${loggedInTime} seconds`
);
}, 1000);
return () => clearInterval(interval);
}, []);
我们使用新的 useEffectEvent Hook 创建一个 getter 函数,它返回 userName 的当前值。它可以在 useEffect 中被调用,它永远不会过期。它真的很干净。比 useRef 版本干净和清晰得多。
但实际上还能变得更好,因为它允许我们更一般地思考 useEffect。我是说,如果你想一想,我们多少有一个更通用的"计时器" useEffect:
javascript
const onTick = useEffectEvent((tick: number) =>
setLoginMessage(`${userName} has been logged in for ${tick} seconds`)
);
useEffect(() => {
let ticks = 0;
const interval = setInterval(() => onTick(++ticks), 1000);
return () => clearInterval(interval);
}, []);
现在我们已将所有状态相关的内容移到 useEffectEvent 中。看看我们的 useEffect 变得多干净了?useEffect 只处理计时器。onTick 处理该计时器的所有逻辑。
useEffectEvent 是游戏规则改变者
更好的是,useEffect 对状态没有依赖。而状态依赖正是 useEffect 陷入困境的地方(如我们所见)。依赖于错误状态的坏依赖数组会导致过期闭包问题、无效重置或甚至无限循环。useEffectEvent 允许我们从依赖数组中移除状态。这帮助我们编写更好的 useEffects。
我们甚至可以将其变成自定义 Hook,使其更通用:
javascript
function useInterval(onTick: (tick: number) => void) {
const onTickEvent = useEffectEvent(onTick);
useEffect(() => {
let ticks = 0;
const interval = setInterval(() => onTickEvent(++ticks), 1000);
return () => clearInterval(interval);
}, []);
}
现在我们有了一个完整的 useInterval 实现,它非常干净且无 bug。
一个小挑战
如果你想要一个有趣的小挑战:你将如何实现一个版本,其中毫秒数(目前是 1000)是可调整的?
javascript
function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
// ????
}
我采取的方法这次有点不同。不是 setInterval,我使用 setTimeout 并在每次迭代中调整超时:
javascript
function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
const onTickEvent = useEffectEvent(onTick);
const getTimeout = useEffectEvent(() => timeout);
useEffect(() => {
let ticks = 0;
let mounted = true;
function onTick() {
if (mounted) {
onTickEvent(++ticks);
setTimeout(onTick, getTimeout());
}
}
setTimeout(onTick, getTimeout());
return () => {
mounted = false;
};
}, []);
}
让我知道你是否能够优化这个。
结论:带安全带的 React
React 团队承认了 useEffect 代码失控运行的问题。他们不仅承认了这一点,还用像编译器和 useEffectEvent 这样的新功能创造了优雅的解决方案,让你可以安全可靠地编写 React 代码。
不要听信反对者的声音------React 并没有停滞不前,它还在进化,而且在大多数情况下,变得更好。
译者补充
翻译这篇文章是因为它把 useEffect 依赖数组的痛点讲得很透彻。Cloudflare 的例子不是危言耸听,我自己也遇到过类似的问题------一个看起来无害的对象放进依赖数组,结果页面卡死。
关于 useEffectEvent,补充几点:
现状 :好消息是,useEffectEvent 在 React 19.2(2025 年 10 月发布)中已经是稳定 API 了,可以直接使用:
javascript
import { useEffectEvent } from 'react';
如果你还在用 React 19.2 之前的版本,需要用实验前缀:experimental_useEffectEvent。升级到 19.2+ 后记得去掉前缀。
另外,ESLint 插件也需要升级到 eslint-plugin-react-hooks@6,这样 linter 才能正确识别 useEffectEvent 返回的函数不需要加入依赖数组。
原理是什么 :要理解 useEffectEvent,得先明白过期闭包是怎么产生的。
JavaScript 的函数会"记住"创建时的词法环境,这就是闭包。当你写:
javascript
useEffect(() => {
console.log(userName); // 闭包捕获了 userName
}, []);
这个箭头函数在第一次渲染时创建,它捕获的 userName 是当时的值。由于依赖数组是空的,这个函数不会重新创建,所以它永远只能访问到旧值。
useRef 的解决思路是:不让闭包直接捕获值,而是捕获一个"容器"(ref),然后每次渲染时更新容器里的内容:
javascript
const nameRef = useRef(userName);
nameRef.current = userName; // 每次渲染都更新
useEffect(() => {
console.log(nameRef.current); // 闭包捕获的是 ref 对象,读取时拿到最新值
}, []);
useEffectEvent 本质上是 React 帮你做了这套事情,但更优雅:
- 它返回的函数引用是稳定的------每次渲染返回的都是同一个函数对象,所以不需要加入依赖数组
- 但这个函数内部能访问到最新的 props/state------React 在内部维护了一个类似 ref 的机制,每次渲染时更新函数要访问的值
你可以把它理解为 React 官方提供的 useRef + "自动同步" 的封装,只是语义更清晰:这是一个"事件",它描述的是"发生某事时要做什么",而不是 effect 本身的逻辑。
什么时候用 :文章里的例子是计时器,但更常见的场景是埋点。比如页面加载时发送一次埋点,埋点数据里有用户信息、页面参数等。这些数据可能会变,但你不希望它们变化时重新触发埋点------这正是 useEffectEvent 的典型用例。
还没升级 React 19 怎么办 :如果项目还在 React 18,文章里的 useRef 方案是靠谱的。社区也有一些封装好的 Hook,比如 ahooks 的 useMemoizedFn,思路类似。
配合 React Compiler :React Compiler 能自动推断依赖,配合 useEffectEvent 效果更好。两者都是 React 团队为了解决 useEffect 心智负担而推出的方案,可以关注一下这个组合。
如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:
Claude Code Skills (按需加载,意图自动识别,不浪费 token,介绍文章):
- code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则(详细介绍)
- 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
- first-principles-skill - 第一性原理思考,适合架构设计和技术选型
qwen/gemini/claude - cli 原理学习网站:
- coding-cli-guide(学习网站)- 学习 qwen-cli 时整理的笔记,40+ 交互式动画演示 AI CLI 内部机制

全栈项目(适合学习现代技术栈):
- prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
- chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB