一句话定义
useEffect 是让组件与渲染系统之外的世界保持同步的钩子:在每次提交并绘制后,React 按依赖数组执行(或在变化时先清理再重跑)你的 effect 函数。
为什么重要
副作用的边界决定代码正确性:数据请求、订阅、定时器、localStorage、改标题……都必须走 effect;而 90% 的「useEffect 疑难杂症」源于把不需要 effect 的事塞进了 effect。学会「何时不用」与「何时用」同样重要。
前置知识
核心概念
- 副作用(Side Effect):影响渲染之外世界的操作——网络、订阅、定时器、全局存储、DOM API。
- 依赖数组:
[]仅首次执行;[a, b]在 a 或 b 变化后执行;不传则每次渲染后执行(慎用)。 - 清理函数:effect 返回的函数,在「下次重跑前」和「组件卸载时」执行,负责回收订阅/定时器/请求。
- 同步 vs 事件:「渲染导致的响应」放 effect;「用户操作导致的响应」放事件处理器——这是官方 split 逻辑。
- useLayoutEffect:提交后、浏览器绘制前同步执行,用于测量 DOM/防闪烁;默认用 useEffect。
原理 / 机制
时序图:
text渲染(计算新虚拟DOM) → 提交(写入DOM)
→ [useLayoutEffect 同步执行] → 浏览器绘制
→ [useEffect 异步执行] → [清理函数先于重跑执行]
标准模式:订阅 + 清理
jsxfunction useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
function update() { setOnline(navigator.onLine); }
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []); // 只订阅一次;更新走事件回调(外部事件驱动,不需要依赖)
return online;
}
数据请求的「竞态」问题与修正:
jsxuseEffect(() => {
let ignore = false; // 竞态守卫
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(json => { if (!ignore) setData(json); });
return () => { ignore = true; }; // 下一次请求开始时,使旧结果作废
}, [query]);
请求 A 慢于请求 B 返回时,没有守卫会把 A 的旧数据覆盖到新 query 上——这是 effect 数据请求第一大坑。
「不需要 effect」的三类典型(官方 You Might Not Need an Effect 的浓缩):
jsx// 1. 由 state/props 派生的值 → 渲染时直接算
const fullName = firstName + ' ' + lastName; // 不要 useEffect+setState
// 2. 事件驱动的状态变化 → 放事件处理器
// 不要: [submitted, setSubmitted] + useEffect(() => { if (submitted) send() })
// 而是: 在提交按钮的 onClick 里直接 send()
// 3. 组件挂载时的"镜像到全局" → 例外才是 effect,且清理必须对称
直观类比
effect 像搬家后约水电:装修(渲染/提交)完成、人已入住(绘制)之后才去办(异步执行);每搬一次家(依赖变化重跑),要先退订旧服务(清理)、再订新服务。依赖数组是「搬家触发器」——你只声明「哪些变化意味着该重新同步」,React 负责在正确时机提醒你。
实例 / 案例
localStorage 持久化(学习路径第二阶段项目用到的形状):
jsxfunction usePersistentState(key, initial) {
const [value, setValue] = useState(() => {
// 惰性初始化:只在首渲染执行一次昂贵读取
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]); // value 变 → 同步写盘;key 变 → 写到新位置
return [value, setValue];
}
注意与 kp-015 的衔接:这正是一个自定义 Hook 的雏形。
常见误区
- 用 effect 做派生计算:
useEffect(() => setTotal(a + b), [a, b])—— 多一次渲染、可能闪旧值;直接渲染时算。 - 依赖数组「为了让 lint 闭嘴」而删项:effect 读到的将是过期快照,产生最难查的 bug。正确动作是修复依赖或重构。
- 清理不对称:订阅了没退订 → 泄漏;
setTimeout没 clear → 卸载后 setState 警告。 - 在 effect 里改 state 导致循环:
setX后依赖里又有 x → 同步循环;确认这是「渲染派生」还是「事件驱动」,移回对应位置。 - 把 effect 当生命周期方法用:
[]不是 "componentDidMount",而是「与 [] 中任何东西无关的同步」;心智不同,写法就不同。
自测题
- 写出 effect 完整执行时序(含清理、layout effect、绘制)。
- 为什么请求竞态需要
ignore守卫?试着描述一次会出 bug 的时序。 - 判断并改写:「点击保存按钮后要弹 toast」——你用 useEffect 监听 saved 状态变化来弹,对吗?
useEffect(() => {...}, [])与「挂载时执行一次」的语义差别是什么?StrictMode 下前者会执行几次、为什么?- 把 kp-010 的
usePersistentState中[key, value]改成[]会发生什么?
与其他知识点的关系
- 向前:依赖 kp-008 的提交时序。
- 向后:kp-014 解释「依赖数组为什么必须诚实」(闭包);kp-015 把 effect 打包复用;kp-017 用 ref 存「effect 间共享的可变量」;kp-025 的 Actions 在表单场景取代一部分手写 effect。
延伸阅读
- Synchronizing with Effects:<https://react.dev/learn/synchronizing-with-effects>
- You Might Not Need an Effect(本篇最重要的延伸读物):<https://react.dev/learn/you-might-not-need-an-effect>
- useEffect API:<https://react.dev/reference/react/useEffect>