原文链接: peterkellner.net/2026/01/09/…
作者:Peter Kellner
TL;DR
useEffectEvent 允许你在 Effect 中读取最新的 props/state,而无需将其添加到依赖数组中。当你需要一个始终能看到当前值的稳定回调函数时,它消除了使用 useRef 变通方案的必要性。跳转至对比说明。

# 引言
若你曾长期使用 [React](https://react.dev/) 钩子,想必遭遇过这种令人沮丧的场景:在 `useEffect` 中设置订阅或定时器时,回调函数需要读取最新状态。但若将该状态加入依赖数组,每次状态变更时效果器都会重新运行(并重新订阅)。这种做法轻则造成资源浪费,重则导致功能失效。
传统解决方法?将状态镜像到 useRef 中,使回调无需添加依赖即可读取。虽然可行,但冗余代码多且易出错。
React 19.2 推出的 useEffectEvent 提供了优雅解决方案。该钩子创建稳定函数,调用时始终读取最新值——无需在 Effect 依赖中显式添加这些值。
本文将带您了解:
-
useEffectEvent解决的核心问题
- 旧版
useRef变通方案及其缺陷
-
useEffectEvent的底层工作原理
- 两种方案的实战示例
- 关键规则与注意事项
问题:效果中的陈旧闭包
让我们从一个具体问题开始。你正在构建一个连接聊天室的聊天应用。当收到消息时,你希望显示通知——但仅当通知功能已启用时才显示。
以下是看似"显而易见"却行不通的方法:
import { useEffect, useState } from "react";
function ChatRoom({ roomId }: { roomId: string }) {
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
// BUG: This captures the initial value of notificationsEnabled
// It will NEVER see updates when the user toggles the checkbox!
if (notificationsEnabled) {
showNotification(message);
}
});
return () => connection.disconnect();
}, [roomId]); // notificationsEnabled is NOT in deps
return (
<label>
<input
type="checkbox"
checked={notificationsEnabled}
onChange={(e) => setNotificationsEnabled(e.target.checked)}
/>
Enable notifications
</label>
);
}
connection.on("message", ...) 中的回调函数创建了一个闭包,该闭包捕获了效果运行时的 notificationsEnabled 值。由于 notificationsEnabled 未包含在依赖数组中,因此当 roomId 发生变化时,该效果仅会触发一次。回调函数将永远看到原始值。
制造新问题的“修复方案”
你可能会想:“简单,只要在依赖项中添加notificationsEnabled就行了!”
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
if (notificationsEnabled) {
showNotification(message);
}
});
return () => connection.disconnect();
}, [roomId, notificationsEnabled]); // Now notificationsEnabled is a dep
现在回调函数能看到最新值了……但有个问题。每次 notificationsEnabled 改变时,效果都会重新运行。这意味着:
- 断开房间连接
- 重新连接房间
- 重新注册消息处理器
切换通知复选框不该导致聊天重新连接!这将导致糟糕的用户体验——重新连接期间可能遗漏消息,服务器连接数激增,纯属资源浪费。
这正是 useEffectEvent 解决的核心矛盾:某些值应触发 Effect 重跑(如roomId),而另一些值仅需在需要时读取但不触发重跑(如notificationsEnabled)。
旧式解决方法:useRef
在useEffectEvent出现之前,标准做法是通过将值镜像到useRef中来"逃逸"闭包:
import { useEffect, useRef, useState } from "react";
function ChatRoom({ roomId }: { roomId: string }) {
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
// Mirror the value into a ref
const notificationsEnabledRef = useRef(notificationsEnabled);
// Keep the ref in sync with state
useEffect(() => {
notificationsEnabledRef.current = notificationsEnabled;
}, [notificationsEnabled]);
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
// Read from the ref instead of the closure
if (notificationsEnabledRef.current) {
showNotification(message);
}
});
return () => connection.disconnect();
}, [roomId]); // Only roomId triggers reconnection
return (
<label>
<input
type="checkbox"
checked={notificationsEnabled}
onChange={(e) => setNotificationsEnabled(e.target.checked)}
/>
Enable notifications
</label>
);
}
这确实有效!连接效果仅在roomId变更时重新运行。消息处理器从notificationsEnabledRef.current读取数据,该值始终保持最新状态。
useRef模式存在的问题
- 冗余代码:每次需要"逃逸"的值都需创建
ref、同步效果,并记得读取.current
- 易遗漏:新增回调所需值时,必须额外添加
ref 和同步 Effect
- 组件冗余:核心逻辑被
ref 管理层掩盖
- 无法通过代码检查:ESLint 钩子规则无法验证
ref 使用正确性
使用useEffectEvent
useEffectEvent提供了一种一流的解决方案。它返回一个稳定函数,调用时始终使用最新的props和state执行。
import { useEffect, useState, useEffectEvent } from "react";
function ChatRoom({ roomId }: { roomId: string }) {
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
// Create an Effect Event that reads latest values
const onMessage = useEffectEvent((message: string) => {
if (notificationsEnabled) {
showNotification(message);
}
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", onMessage);
return () => connection.disconnect();
}, [roomId]); // Only roomId triggers reconnection
return (
<label>
<input
type="checkbox"
checked={notificationsEnabled}
onChange={(e) => setNotificationsEnabled(e.target.checked)}
/>
Enable notifications
</label>
);
}
请注意以下差异:
- 无引用
- 无同步效果
- 无
.current 读取
-
onMessage 函数保持稳定(在不同渲染间具有相同标识)
- 但调用时会看到当前
notificationsEnabled 的值
并列对比:顿悟时刻
让我们通过一个实际案例来对比两种方法:使用分析工具追踪页面访问量。
问题描述
您希望在URL变更时记录页面访问,日志应包含:
- 访问的URL(响应式——应触发日志)
- 当前购物车商品数量(非响应式——不应触发新日志)
未使用useEffectEvent(useRef替代方案)
import { useContext, useEffect, useRef } from "react";
import { ShoppingCartContext } from "./cart";
function Page({ url }: { url: string }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
// Step 1: Create a ref to hold the latest value
const numberOfItemsRef = useRef(numberOfItems);
// Step 2: Keep the ref synchronized
useEffect(() => {
numberOfItemsRef.current = numberOfItems;
}, [numberOfItems]);
// Step 3: Use the ref in your Effect
useEffect(() => {
logVisit(url, numberOfItemsRef.current);
}, [url]); // Only url triggers re-run
}
解决方法所需代码行数:8(引用声明、同步效果、读取.current)
使用useEffectEvent
import { useContext, useEffect, useEffectEvent } from "react";
import { ShoppingCartContext } from "./cart";
function Page({ url }: { url: string }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
// Create an Effect Event for the non-reactive logic
const onVisit = useEffectEvent((visitedUrl: string) => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // Only url triggers re-run
}
代码行数:4(仅包含Effect事件和Effect)
关键洞察:参数与捕获值的区别
注意我将url作为参数传递给onVisit(),而非直接在Effect事件内部读取。这是有意为之,且符合React文档的建议。
当将 url 作为参数传递时:
- 不同 URL 明确代表不同"事件"
- 响应式值通过函数调用显式传递
当在效果事件内部读取 numberOfItems 时:
这种模式清晰区分了响应式与非响应式逻辑。
这是魔法吗?(剧透:不,只是JavaScript)
初次见到useEffectEvent时,我的反应是:"等等,这怎么实现的?回调函数居然...能自动获取最新状态?React在搞什么魔法?"
答案是否定的。这里没有魔法,没有特殊编译技巧,也没有隐藏的React内部机制在做不可思议的事。useEffectEvent 实现的正是你手动使用 useRef 时所做的操作——React 只是将这种模式自动化了。
让我们通过图表逐步揭开谜底。
直观理解闭包失效问题
首先可视化闭包失效的原因:当你在组件内部创建回调函数时,它会捕获该次渲染的值:
在渲染1中创建的回调函数捕获了count=0的值。即使组件使用新值重新渲染,该回调函数仍保留其原始闭包。当事件最终触发时,它读取的是过时的值。
useRef如何解决此问题
useRef模式之所以有效,是因为ref提供了一个具有稳定标识的可变容器:
关键洞见:ref对象本身永远不会改变身份。回调函数的闭包捕获了ref对象(保持不变),当它运行时,会读取.current(已被更新)。间接引用解决了问题!
useEffectEvent的工作原理(揭秘其"魔力")
现在揭晓答案:useEffectEvent的实现原理完全相同。React创建了一个稳定的封装函数,该函数委托给内部持有最新回调的ref对象:
当你调用useEffectEvent返回的包装函数时:
- 它不会直接执行你的回调函数
- 而是从内部引用中查找最新版本
- 然后调用该最新版本
-
这就是为什么返回的函数具有稳定的特性(跨渲染的相同函数引用),但读取最新值(因为它委托给刚更新的回调函数)。
等效表达
以下代码实现了相同效果:
使用 useRef 的实现:
// Manual approach: 8 lines
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
const handler = useCallback(() => {
console.log(countRef.current);
}, []);
useEffectEvent的实现:
// useEffectEvent: 3 lines
const handler = useEffectEvent(() => {
console.log(count);
});
相同的行为。更少的代码。所谓的“魔法”不过是React自动实现了众所周知的模式。
概念性实现
以下是useEffectEvent在底层的核心工作原理:
// This is NOT the actual React implementation, just a mental model
function useEffectEvent<T extends (...args: any[]) => any>(callback: T): T {
// This ref holds the latest callback
const latestCallbackRef = useRef(callback);
// Update the ref after each render (synchronously, before Effects run)
latestCallbackRef.current = callback;
// Return a stable wrapper that calls the latest callback
const stableWrapper = useCallback((...args: Parameters<T>) => {
return latestCallbackRef.current(...args);
}, []);
return stableWrapper as T;
}
实际实现更为复杂(它与React内部的Fiber架构深度集成),但这抓住了核心本质:一个稳定的标识符包裹着一个持续更新的回调函数。
为何这很重要
理解 useEffectEvent 并非魔法具有实际益处:
- **调试:**当出错时,你能理性分析——它本质只是引用和回调
- **思维模型:**你理解规则存在的缘由(如"仅限从效果器调用")
- **回退知识:**在旧版 React 中,你清楚如何复现行为
- **信心:**你不再依赖"它就是管用"——而是理解其机制
最优秀的抽象并非神秘的黑盒,而是对已知模式的便捷封装。
更复杂的示例:静音聊天连接
以下示例充分展现了其价值。设想一款聊天应用:
- 切换聊天室时应重新连接
- 切换静音状态时不应重新连接
显而易见的笨拙方法
function Chat({ roomId }: { roomId: string }) {
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
// BUG: isMuted is stale!
if (!isMuted) {
playMessageSound();
}
addMessageToChat(message);
});
return () => connection.disconnect();
}, [roomId]); // isMuted not in deps = stale
return (
<button onClick={() => setIsMuted(!isMuted)}>
{isMuted ? "Unmute" : "Mute"}
</button>
);
}
随心切换静音状态——声音播放却基于初始的isMuted值。
重新连接的“修复”
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
if (!isMuted) {
playMessageSound();
}
addMessageToChat(message);
});
return () => connection.disconnect();
}, [roomId, isMuted]); // Now it works... but reconnects on mute toggle
此方法可行但用户体验极差。每次静音切换都会:
- 断开聊天连接
- 重新连接
- 可能在重新连接期间遗漏消息
useRef 替代方案
function Chat({ roomId }: { roomId: string }) {
const [isMuted, setIsMuted] = useState(false);
const isMutedRef = useRef(isMuted);
useEffect(() => {
isMutedRef.current = isMuted;
}, [isMuted]);
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", (message: string) => {
if (!isMutedRef.current) {
playMessageSound();
}
addMessageToChat(message);
});
return () => connection.disconnect();
}, [roomId]);
return (
<button onClick={() => setIsMuted(!isMuted)}>
{isMuted ? "Unmute" : "Mute"}
</button>
);
}
运行正常,但会添加引用模板代码。
Clean useEffectEvent 解决方案
import { useEffect, useState, useEffectEvent } from "react";
function Chat({ roomId }: { roomId: string }) {
const [isMuted, setIsMuted] = useState(false);
const onMessage = useEffectEvent((message: string) => {
if (!isMuted) {
playMessageSound();
}
addMessageToChat(message);
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on("message", onMessage);
return () => connection.disconnect();
}, [roomId]); // Clean: only roomId triggers reconnection
return (
<button onClick={() => setIsMuted(!isMuted)}>
{isMuted ? "Unmute" : "Mute"}
</button>
);
}
无引用、无额外效果、无冗余代码。关注点分离明确:
-
响应式(roomId): 变更触发重新连接
-
非响应式(isMuted): 按需读取最新值,不触发重新连接
规则与注意事项
useEffectEvent 功能强大,但需遵循重要规则。eslint-plugin-react-hooks(6.1.1+版本)会强制执行这些规则。
规则一:仅在效果器内部调用效果事件
效果事件的设计目的仅限于在效果器内部调用。它们并非通用型稳定回调函数。
// ✅ Correct: Called from inside an Effect
const onMessage = useEffectEvent((msg: string) => {
console.log(msg, latestState);
});
useEffect(() => {
socket.on("message", onMessage);
return () => socket.off("message", onMessage);
}, []);
// ❌ Wrong: Called from an event handler
<button onClick={() => onMessage("hello")}>
Click me
</button>
// ❌ Wrong: Called during render
return <div>{onMessage("rendered")}</div>;
对于常规事件处理器(如onClick、onChange等),无需使用useEffectEvent。由于处理器在每次渲染时都会创建,因此每次运行时都会获得最新值。
规则二:不要将效果事件传递给其他组件
效果事件应局限于其所属组件内部。请勿将其作为 props 传递:
// ✅ Correct: Keep Effect Events local
function Parent() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
console.log(count);
});
useEffect(() => {
const id = setInterval(() => onTick(), 1000);
return () => clearInterval(id);
}, []);
return <div>Count: {count}</div>;
}
// ❌ Wrong: Passing Effect Event as a prop
function Parent() {
const onTick = useEffectEvent(() => {
console.log(latestCount);
});
return <Timer onTick={onTick} />; // Don't do this!
}
若需构建需要回调参数的自定义钩子,请在钩子内部而非外部定义效果事件。
规则三:在使用效果事件之前声明
将效果事件声明置于其使用位置附近:
// ✅ Good: Effect Event declared right before its Effect
function Component() {
const [value, setValue] = useState(0);
const onInterval = useEffectEvent(() => {
console.log("Current value:", value);
});
useEffect(() => {
const id = setInterval(() => onInterval(), 1000);
return () => clearInterval(id);
}, []);
}
// ❌ Avoid: Effect Event far from its Effect (confusing)
function Component() {
const [value, setValue] = useState(0);
const onInterval = useEffectEvent(() => { /* ... */ });
// ... 50 lines of other code ...
useEffect(() => {
const id = setInterval(() => onInterval(), 1000);
return () => clearInterval(id);
}, []);
}
规则4:不要使用useEffectEvent来抑制代码检查器警告
这关乎意图。useEffectEvent用于分离响应式与非响应式逻辑——而非用于屏蔽exhaustive-deps代码检查规则。
// ✅ Correct: page SHOULD be a dependency because you WANT to refetch when it changes
useEffect(() => {
async function fetchData() {
const data = await fetch(`/api/items?page=${page}`);
setItems(data);
}
fetchData();
}, [page]); // Correctly triggers refetch on page change
// ❌ Wrong mental model: "I'll use useEffectEvent so I don't have to list dependencies"
const fetchData = useEffectEvent(async () => {
const data = await fetch(`/api/items?page=${page}`);
setItems(data);
});
useEffect(() => {
fetchData();
}, []); // "Now I don't need page in deps!" <- Wrong!
需要思考的问题是:“当该值发生变化时,是否需要重新运行效果?”若答案为是,则属于依赖关系;若答案为否(仅需在其他触发器启动效果时读取最新值),则应使用效果事件。
何时应使用 useEffectEvent?
当效果器内部的回调函数满足以下条件时,请使用 useEffectEvent:
-
被传递给订阅器、定时器或外部库,且不希望重新注册
- 调用时需要读取最新的 props/state
- 这些值不应触发效果器重新运行
常见场景:
| 场景 |
响应式(触发效果) |
非响应式(效果事件) |
| 聊天室连接 |
roomId |
isMuted, theme
|
| 分析日志记录 |
pageUrl |
cartItemCount, userId
|
| 间隔计数器 |
- (仅运行一次) |
count, step
|
| WebSocket消息 |
socketUrl |
isOnline, preferences
|
| 动画帧 |
- (仅运行一次) |
currentPosition |
关于 React 版本说明
useEffectEvent 作为稳定功能在 React 19.2 中引入。若您使用的是早期版本:
-
React 18.x 及更早版本: 请使用上述描述的
useRef 模式
-
React 19.0-19.1:
useEffectEvent 可用但处于实验阶段
-
React 19.2+: 可放心使用
useEffectEvent
您可通过以下方式检查 React 版本:
npm list react
总结
useEffectEvent 解决了 React 钩子中长期存在的痛点:在 Effect 内部访问最新状态/属性时,避免触发不必要的重新运行。
之前: 将值镜像到 ref 中,添加同步 Effect,读取 .current——所有这些都是手动操作且易出错的冗余代码。
之后: 将回调函数包裹在 useEffectEvent 中,让 React 自动保持其更新。
核心思维模型:
-
依赖关系回答:"何时应重新运行此 Effect?"
-
Effect Events回答:"Effect 运行时应读取哪些值?"
通过明确分离这些关注点,代码更清晰,更不易引入错误。
延伸阅读