useEffect 的正确打开方式

0 阅读5分钟

如果你做过 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

qrcode_for_gh_6a9e7f3719d6_344.jpg