如果你做过 React 代码评审,大概率见过这种场面:一个组件里塞了七八个 useEffect,每个都在监听某个 state,然后 setState 触发下一个 Effect——整个组件像一台鲁布·戈德堡机器,一个弹珠从顶部落下,经过十七道机关才到终点。
React 官方文档有一篇很少人认真读完的长文:You Might Not Need an Effect。它的核心结论只有一句话:
useEffect 不是默认逻辑容器,而是与 React 外部系统同步的逃生通道。
今天我把这篇文档的精华拆解给你——不是翻译,而是帮你建立一个清晰的判断框架:什么时候该用 Effect,什么时候你只是在给自己制造问题。
一、两条管辖规则:谁该审这个案子?
把 useEffect 想象成一个法庭。不是所有事情都该送到这里来审。React 的世界里有两条清晰的"管辖权"划分:
| 代码该运行的原因 | 该写在哪里 |
|---|---|
| 因为组件被展示给用户 | Effect ✅ |
| 因为用户做了某个操作 | 事件处理器 ✅ |
就这么简单。用户点了"购买"按钮,你要发一个 POST 请求——这是事件处理器的管辖范围。组件挂载后你要向分析服务上报"页面曝光"——这才是 Effect 的活儿。
把用户行为的响应逻辑丢给 Effect,就像把民事案件送进军事法庭——管辖错误。
二、最常见的错误:派生值存成了 state
来看一个真实到离谱的例子:
// ❌ 经典反模式
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
}
fullName 是什么?它是 firstName + lastName 的计算结果。你把一个能随时算出来的值,先存一份到 state,再用 Effect 去"同步"它——这就像会计把"利润"手抄到另一本账簿里,每次收入或支出变了还得记着回去改。
真正的会计不会这么干。利润 = 收入 - 支出,需要的时候拿计算器算就行:
// ✅ 正确做法:render 就是你的计算器
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName; // 直接算
}
没有多余的 state,没有 Effect,没有同步延迟——代码更快、更短、更不容易出 bug。
能用算的,就不要用存的。
三、Effect 链:每个红灯都在浪费你的时间
来看一个游戏逻辑的例子——选到金色卡牌加分、满 3 张进入下一轮、超过 5 轮游戏结束:
// ❌ Effect 链式反应
useEffect(() => {
if (card?.gold) setGoldCardCount(c => c + 1);
}, [card]);
useEffect(() => {
if (goldCardCount > 3) { setRound(r => r + 1); setGoldCardCount(0); }
}, [goldCardCount]);
useEffect(() => {
if (round > 5) setIsGameOver(true);
}, [round]);
这是什么?这是城市里的信号灯级联:
setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染
用户点一次,组件渲染 四次。中间三次都是废的——就像从你家到公司明明有一条直达高速,你偏要走辅路过三个红灯路口。
正确做法?修一条直达高速——在事件处理器中一次算完所有 state:
// ✅ 一次性计算,直达终点
function handlePlaceCard(nextCard) {
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) alert('Good game!');
}
}
}
一次点击,一次渲染。没有链式反应,没有中间状态闪烁。
Effect 链的本质问题不是性能——是脆弱性。 如果你想加一个"回放历史步骤"功能,设置 card 为过去的值会再次触发整条链,产出错误结果。而事件处理器版本?你可以完全控制哪些 state 该更新、哪些不该。
四、prop 变了想重置 state?别用 Effect,用 key
// ❌ 用 Effect 重置
useEffect(() => {
setComment('');
}, [userId]);
// ✅ 用 key 让 React 重建组件
<Profile userId={userId} key={userId} />
传 key 就是告诉 React:不同的 userId 对应不同的组件实例。React 会销毁旧实例、创建新实例,所有 state 自动清零——不需要你手动"打扫房间"。
这也是 React 设计哲学的一个缩影:能用声明式描述的,就不要用命令式操作。 "我要一个全新的 Profile"比"我要把 comment 设为空、把 input 清掉、把 scroll 重置..."简洁一百倍。
五、数据获取:cleanup 是生死线
如果你确实需要在客户端用 Effect 获取数据(没有 Server Component 可用),有一个 bug 几乎所有初学者都会踩——竞态条件:
快速输入搜索词 "hello" 时,"h"、"he"、"hel"、"hell"、"hello" 各发一个请求。响应到达顺序不确定——"hell" 的结果可能在 "hello" 之后到达并覆盖正确数据。
修复方法是加 cleanup 忽略过期响应:
useEffect(() => {
let ignore = false;
fetchResults(query).then(json => {
if (!ignore) setResults(json);
});
return () => { ignore = true; }; // 过期标记
}, [query]);
cleanup 函数不是可选装饰品,它是防止数据错乱的生死线。
六、一张判断表,覆盖 90% 的场景
| 你想做的事 | 正确方案 |
|---|---|
| 从 props/state 算出一个值 | 渲染时直接计算 |
| 计算量大需要缓存 | useMemo(或等 React Compiler 自动处理) |
| prop 变了要重置所有 state | 传不同的 key |
| 用户点击后发请求 | 事件处理器 |
| 组件显示时上报曝光 | useEffect ✅ |
| 监听窗口/网络等外部事件 | useSyncExternalStore |
| 客户端 fetch 数据 | useEffect + cleanup(或框架内置方案) |
七、2026 年的新趋势:Effect 的领地正在缩小
React 19 + Server Components 的普及,让 useEffect 的"合法领地"进一步缩小:
• 数据获取:Server Component 直接在服务端 async/await,不需要客户端 Effect
• 表单提交:Server Actions 让 <form action={serverFn}> 取代了 onSubmit + fetch
• 性能优化:React Compiler 自动记忆化,2026 年早期采用者报告显示手动 memo 需求减少 50%,不必要的重渲染减少 30-40%
但 Effect 不会消失。它仍然是正确工具——当你需要:
• 与第三方 DOM 库(D3、Chart.js)同步
• 监听 WebSocket / Firebase 实时数据
• 管理浏览器 API(Intersection Observer、全局快捷键)
Effect 的定位从未改变:它是与 React 体系之外的世界同步的桥梁。 只是过去太多不该过这座桥的逻辑,被错误地赶上了桥。
如果你只想带走一句话,我建议记这个:
先问"这段代码为什么要运行"——如果答案是"因为用户做了什么",它就不属于 Effect。
参考原文:
• React 官方团队 — You Might Not Need an Effect