找出你不需要的 React Effect
使用 eslint-plugin-react-you-might-not-need-an-effect 检查不必要的 React effect,并重构派生状态、事件处理逻辑和基于 props 的状态重置。
eslint-plugin-react-you-might-not-need-an-effect 是一个 ESLint 和 Oxlint 插件,用于标记 React 文档认定为不必要的 useEffect 调用,例如派生 state、转发事件或向父组件传递数据的 effect。
你读过 You Might Not Need an Effect,频频点头,然后打开一个有四百处 useEffect 调用的代码库,却没有切实可行的办法判断哪些是问题所在。
本文介绍如何安装该插件、它在一个贴近实际的组件上会报告什么、如何重构四类最常见的问题,以及什么情况下应该抑制警告而不是修复它。如需回顾该 Hook 本身,请参阅这篇 React useEffect Hook 指南。
核心要点
- eslint-plugin-react-you-might-not-need-an-effect 提供九条规则和两个 flat config 预设:
recommended将所有规则报告为警告,strict将所有规则报告为错误。使用旧版 .eslintrc 的项目可使用对应的legacy-recommended和legacy-strict预设。 - 对于仅依赖当前 props 和 state 的值,应在渲染期间计算,而不是通过 effect 将其复制到 state 中。
- 若要在某个 prop(例如客户 ID)变化时重置组件的全部 state,可将该 prop 作为组件的
key进行渲染。 - 让 React 与外部系统(如定时器、DOM API、非 React 组件或网络连接)保持同步的 effect,正是
useEffect存在的意义,应予以保留。 - 抑制警告时,使用
eslint-disable-next-line注释并注明规则名,在--之后写明原因。
什么样的 React Effect 是不必要的?
大多数不必要的 effect 有两种形态:一种是重新计算某个值并将其存入 state 的 effect,另一种是监听某个 state 并更新另一个 state 以与之匹配的 effect。在这两种情况下,React 都会先用旧值提交一次渲染,然后运行 effect,再用修正后的值重新渲染一次。
不必要的 effect 引发的额外渲染对用户是可见的。在大量使用 effect 的组件的会话回放中,可以看到过滤后的列表为空或合计金额显示为占位值,持续一帧后才由第二次渲染填充。解决办法几乎总是移除该 state,而不是调整 effect。
安装并运行插件
该插件以开发依赖的形式安装,并通过其 recommended 预设接入 ESLint flat config。如果你已经在使用多个 ESLint 插件,它可以与之共存,不会产生冲突。
npm install --save-dev eslint-plugin-react-you-might-not-need-an-effect
// eslint.config.js
import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
import globals from "globals";
export default [
reactYouMightNotNeedAnEffect.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
];
本文示例基于 1.0.2 版本,根据插件的 CHANGELOG,该版本发布于 2026 年 8 月 20 日。同一份 changelog 还记录了 no-pass-ref-to-parent 和 no-empty-effect 已在 1.0 之前移除,因此请从旧配置中删除这两条规则。Oxlint 用户可通过 Oxlint 的 jsPlugins 字段加载该插件。在 Oxlint、旧版配置以及使用预设的 flat config 中,规则 ID 均使用 kebab-case 形式的 react-you-might-not-need-an-effect/ 前缀,因为预设是以包名注册插件的。只有当你像 README 中单条规则示例那样,自行以 camelCase 键名注册插件时,才使用 reactYouMightNotNeedAnEffect/ 前缀。
下面是一个包含四个 effect 的组件,每个 effect 都按照某条规则文档中描述的模式编写:
import { useEffect, useState } from "react";
import { postOrder, useCustomerHistory } from "./api";
export function OrderPanel({ customerId, items, onLoaded }) {
const [total, setTotal] = useState(0);
const [submitted, setSubmitted] = useState(false);
const [note, setNote] = useState("");
const history = useCustomerHistory(customerId);
// Effect 1
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
// Effect 2
useEffect(() => {
if (submitted) postOrder(customerId, items, note);
}, [submitted, customerId, items, note]);
// Effect 3
useEffect(() => {
onLoaded(history);
}, [history, onLoaded]);
// Effect 4
useEffect(() => {
setNote("");
setSubmitted(false);
}, [customerId]);
return (
<form onSubmit={(e) => { e.preventDefault(); setSubmitted(true); }}>
<textarea value={note} onChange={(e) => setNote(e.target.value)} />
<p>Total: {total}</p>
<button type="submit">Place order</button>
</form>
);
}
运行 npx eslint src/OrderPanel.jsx。在 recommended 预设下,检查结果会以警告形式出现,每行末尾附有规则 ID。每个 effect 对应其触发的规则:
| Effect | 模式 | 规则 |
|---|---|---|
| 1 | 由 props 计算得出的 state | no-derived-state |
| 2 | 将 state 标志用作事件触发器 | no-event-handler |
| 3 | 将获取的数据交给父组件回调 | no-pass-data-to-parent |
| 4 | prop 变化时清空 state | no-adjust-state-on-prop-change(每个 setter 一条警告) |
Effect 4 正是 key 所能解决的情形。在较简单的组件中,no-reset-all-state-on-prop-change 会报告同样的模式;但对于像本例这样在渲染时读取 state 的组件,1.0.2 版本会将每处重置都归入 no-adjust-state-on-prop-change 报告。
完整的规则集及各自的替代方案如下:
| 规则 | 用以下方式替代 effect |
|---|---|
no-derived-state | 在渲染期间计算的值 |
no-chain-state-updates | 合并为一次 state 更新 |
no-event-handler | 写在事件处理函数中的代码 |
no-adjust-state-on-prop-change | 重构 state 或在渲染时计算 |
no-reset-all-state-on-prop-change | 将该 prop 作为 key 传入 |
no-pass-live-state-to-parent | 状态提升,或由 Hook 返回值 |
no-pass-data-to-parent | 在父组件中获取数据,并将结果作为 props 传递 |
no-external-store-subscription | useSyncExternalStore |
no-initialize-state | 在 useState() 中设置初始值 |
该插件会追踪调用链和依赖数组,因此会增加 lint 的工作量。借助 ESLint 的规则性能分析(TIMING=1),可以了解它在你自己的仓库中的开销。README 还建议启用 react-hooks/exhaustive-deps,因为该插件假定你的依赖数组是正确的;同时建议启用 typescript-eslint/no-floating-promises,它有助于插件识别对异步函数的调用。
派生 State:在渲染期间计算
如果某个值可以由现有的 props 和 state 计算得出,就在渲染期间计算它。不要在 state 中保存一份副本再用 effect 去同步。React 文档在根据 props 或 state 更新 state 一节中介绍了这一点。
// Before
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
// After
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
现在合计金额在首次渲染时就是正确的。如果计算开销较大,可以用 useMemo 包裹,但不要把它放回 state 中。
事件处理逻辑:移入处理函数
如果一个 effect 在等待提交或点击处理函数中设置的 state 标志,那它其实是一个伪装起来的事件处理函数。应当直接在处理函数中执行这段逻辑。相关原理详见将事件与 Effect 分开。
// Before
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) postOrder(customerId, items, note);
}, [submitted, customerId, items, note]);
// After
function handleSubmit(e) {
e.preventDefault();
postOrder(customerId, items, note);
}
“修改前”的版本存在一个真实的 bug:提交后再编辑备注,会导致订单被再次提交。从 0.10.1 开始,no-event-handler 也会标记以这种方式使用的 props;从 0.10.2 开始,它会在使用位置而非声明位置报告问题。
数据获取与向上传递数据
如果子组件获取数据后通过 effect 将其交给父组件,就应该把这项工作交给父组件、框架的数据加载机制或数据获取库,并以 props 的形式接收结果。React 在向父组件传递数据中描述了同样的反转思路。
// Before: child fetches, then pushes up
const history = useCustomerHistory(customerId);
useEffect(() => {
onLoaded(history);
}, [history, onLoaded]);
// After: parent owns the data
function OrderPage({ customerId, items }) {
const history = useCustomerHistory(customerId); // loader, library, or parent effect
return <OrderPanel customerId={customerId} items={items} history={history} />;
}
数据单向流动时,哪个组件拥有数据就一目了然,OrderPanel 也不再需要仅为了上报已加载的内容而接收 onLoaded 回调 prop。
prop 变化时重置 State:使用 key
若要在某个 prop(例如客户 ID)变化时重置组件的全部 state,可将该 prop 作为组件的 key 进行渲染,使 React 挂载一个全新的实例。这一模式记录在当 prop 变化时重置所有 state 一节中。
// Before: Effect 4 inside OrderPanel
useEffect(() => {
setNote("");
setSubmitted(false);
}, [customerId]);
// After: delete the effect; in the parent
<OrderPanel key={customerId} customerId={customerId} items={items} history={history} />
no-adjust-state-on-prop-change 针对的是这种情况的局部版本,即只有部分 state 响应 prop 而变化;不过正如上面的 lint 结果所示,它也会报告 Effect 4。当只需重置部分 state 时,修复方法通常是重构 state 或在渲染期间计算该值。
误报与应当保留的 Effect
将组件与 React 不负责管理的事物(定时器、DOM API、非 React 组件、网络连接)连接起来的 effect,正是 useEffect 的设计初衷,应保持原样。其中大多数都需要清理函数,详见使用 Effect 进行同步。如果某次 DOM 测量导致明显闪烁,那么在 useEffect 与 useLayoutEffect 之间做出选择,比纠结是否保留该 effect 更为重要。
对外部 store 的订阅是“保留同步类 effect”这一原则的例外。它们应当使用 useSyncExternalStore,该 Hook 在渲染期间读取快照,而不是通过 effect 将其复制到 state 中。no-external-store-subscription 会标记基于 effect 的写法。
对 effect 的静态分析存在一些边界情况。React 官方的 set-state-in-effect 规则会在 effect 同步调用 state setter 时发出警告,而 React 仓库中关于 set-state-in-effect 严格程度的 issue 则对该规则如何严格处理挂载标志和客户端检测模式提出了质疑。对于这类模式的警告,应将其视为检查代码的提示,而不是定论。同时运行两个插件是合理的:React 的规则覆盖 effect 中的 setState,而本插件额外覆盖父组件回调、链式更新和 store 订阅。
在抑制警告之前,请先自问:
- 该 effect 是否与 React 之外的系统交互?
- 在依赖变化或组件卸载时,它是否需要清理?
- 将其移到渲染阶段或处理函数中,是否会改变用户可感知的行为?
如果答案表明确实应该保留,就在该行禁用规则,并使用 ESLint 的 -- 描述语法记录原因:
// eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state -- client-only value, avoids hydration mismatch
这一前缀与预设、Oxlint 和旧版配置保持一致。只有在你自行以 reactYouMightNotNeedAnEffect/ 为键名注册插件时,才使用该前缀。
逐步推广
推广 eslint-plugin-react-you-might-not-need-an-effect 时,先使用 recommended 预设,让检查结果以警告形式出现,CI 也能继续通过。然后逐个组件处理这些警告,进行重构或添加注释说明,待警告数降为零后再切换到 strict,这样新增的不必要 effect 就会导致构建失败。
常见问题
能否在旧版 .eslintrc 配置中使用 eslint-plugin-react-you-might-not-need-an-effect?
可以。旧版 .eslintrc 项目可以继承 plugin:react-you-might-not-need-an-effect/legacy-recommended,将所有规则报告为警告;或继承 plugin:react-you-might-not-need-an-effect/legacy-strict,将其报告为错误。在旧版配置中,规则 ID 和禁用注释使用 kebab-case 形式的 react-you-might-not-need-an-effect/ 前缀,与 flat config 预设所用的前缀相同。
什么时候应该用 useMemo 包裹派生值,而不是在每次渲染时计算?
只有当计算确实明显较慢时才使用 useMemo。React 文档建议用 console.time 计时,如果总耗时达到约 1ms 或更多,再进行记忆化。useMemo 会在多次渲染之间缓存结果,同时又不将其放入 state,因此首次渲染时结果依然正确。React Compiler 能够自动记忆化许多计算,因此为派生值手动调用 useMemo 的必要性也随之降低。
更改组件的 key 是否也会重置其子组件的 state?
会。key 变化时,React 会卸载该组件及其整个子树,然后挂载全新的实例。每个子组件都会丢失其本地 state,DOM 节点会被重新创建,所有 effect 都会先执行清理再重新运行。如果只需重置部分 state 而保留子组件的 state,应直接调整该 state 或重构它,而不是使用 key。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k