你是不是遇到过这样的问题:
明明把某个值放进了state里,但在effect里拿到的还是旧值
一个定时器反复启动、停止,代码看起来没毛病
埋点数据在测试环境正常,上线就乱套了
某个功能在本地好用,用户那边却数据混乱
如果是,那你很可能被useEffect的一个隐藏陷阱坑过了。
这个陷阱对初级开发者来说很难察觉(因为代码"看起来"没问题),但对应用的稳定性来说是致命的。本文会把这个陷阱彻底讲清楚——不需要你理解复杂的概念,只需要记住几个硬规则。
真相:useEffect的误解是怎么来的
有句话每个React讲师都说过N次:
"能不用useEffect就别用。"
听起来对,但这句话害了很多开发者。
大家把重点放在"少用"上,却完全无视了更重要的问题:你无法避免useEffect。
侧效(Side Effects)是什么?任何超出React纯渲染流程的操作:
🌐 请求API数据
📨 订阅消息队列
⏰ 启动定时器
🔄 同步非React系统(比如第三方库、原生JS)
📊 发送埋点数据
除了useEffect,没有其他hook能做这些事。useState做不了,useMemo也做不了,signal也不行,refs更不行。
所以问题不是"要不要用",而是——你必须用,但必须用对。
一旦用错,应用就会以各种诡异的方式崩溃。
effect中的值为什么会"卡住"不更新?
让我们从一个日常代码开始,很多人写过类似的:
useEffect(() => { const id = setInterval(() => { trackEvent("user_activity"); setActivityCount(c => c + 1); }, 3000); return () => clearInterval(id); }, []);这段代码想做什么?简单:每3秒记录一次用户活动,然后把计数加1。
但现实是,这段代码永远拿到的是同一个数字。
假设activityCount初始值是0。那么无论用户在你的应用里待了多久,每3秒打出来的数字都是0。10分钟后还是0。1小时后还是0。
为什么会这样?用一个生活中的例子理解
想象你有一台时间冻结机。你第一次按下按钮时,它拍了一张世界的"快照"。然后:
你身上的钱:100元 ✓
你的年龄:25岁 ✓
你的名字:小李 ✓
这台机器记住了这些信息。
现在,真实的世界继续运转:
你工作一年,赚了50万 💰
你又长一岁,现在26岁 🎂
你改名叫"小李强" 📝
但这台时间冻结机还在用旧快照:
你身上的钱:100元(错的!)
你的年龄:25岁(错的!)
你的名字:小李(错的!)
useEffect的依赖数组就是这样的"时间冻结机"。
当你的effect第一次运行时,React会"冻结"那一刻的所有值:trackEvent函数、activityCount状态、所有props。然后effect里的代码就一直在用这些冻结的值,不管真实世界的数据怎么变化。
useEffect(() => { const id = setInterval(() => { // 这里的activityCount永远是0(冻结的值) // 即使外面的activityCount已经变成了5、10、100 console.log(activityCount); // 永远输出0 }, 3000); }, []); // ← 这里是问题根源为什么会这样设计?
这是React的设计决策。effect被设计成"声明式的副作用",而不是"命令式的命令"。
简单说:你不是在说"每3秒运行一段代码",而是在说"如果这些依赖项变了,我需要重新同步这个副作用"。
但如果你告诉React"这个effect的依赖是空的,什么都不需要变"([]),React就认为:"好的,这个副作用永远不需要重新同步。我给你冻结一份快照,永久使用。"
然后bug就来了。
为什么必须写依赖数组?反面教材
现在你可能想:那我把变化的值加到依赖数组里,问题不就解决了?
useEffect(() => { const id = setInterval(() => { trackEvent("user_activity"); setActivityCount(c => c + 1); }, 3000); return () => clearInterval(id); }, [trackEvent, activityCount]); // ← 加上了所有使用过的值理论听起来完美。但实际会发生什么?
新问题:无限循环
每当组件re-render时,trackEvent函数都会被重新创建。而依赖数组一旦检测到trackEvent变了,effect就会重新运行。重新运行意味着:
清除旧的定时器
创建新的定时器
这个过程会反复发生——可能一秒内就发生好几次。你的定时器永远没有机会正常工作,因为它刚启动就被清掉了。
这是从一个坑跳到另一个坑。
React的硬规则
有一条规则写在React官方文档里,不是建议,不是"最佳实践",而是规则:
任何在effect里使用过的值,都必须写在依赖数组里。
这包括:
从外面传进来的props ✓
使用的state ✓
调用的函数 ✓
使用useMemo得到的值 ✓
任何在render时会变化的东西 ✓
如果你用了某个值但没写在依赖数组里,你就是在制造bug。
就像在驾驶证上写"我认为我的反应很快,不用安全带"一样。
问题是:满足这个规则很容易导致无限循环。所以到底该怎么办?
两种解决办法(各有利弊)
方法1:把函数移到effect里面
最简单的办法——干脆别在外面定义函数,直接在effect里用:
useEffect(() => { // trackEvent不在这里定义 // 而是直接在interval回调里写逻辑 const id = setInterval(() => { // 发送埋点 console.log("activity", activityCount); setActivityCount(c => c + 1); }, 3000); return() => clearInterval(id); }, [activityCount]); // 只需要trackEvent用到的状态优点:清晰明了。你能看到effect里用了activityCount,所以它就在依赖数组里。没有歧义。
缺点:activityCount一变化,整个effect就重新运行,定时器也会被重启。(这个例子里是合理的,但其他情况可能浪费性能)
方法2:用useCallback包住函数
如果你的函数需要在effect外面使用(不仅仅是effect里用),可以这样:
const trackEvent = useCallback(() => { console.log("activity", activityCount); }, [activityCount]); // trackEvent会记住最新的activityCount useEffect(() => { const id = setInterval(trackEvent, 3000); return () => clearInterval(id); }, [trackEvent]); // 只依赖trackEventuseCallback的意思是:"这个函数的'身份'很稳定,只有当它内部用到的值变化时,它才会改变。"
优点:effect只依赖trackEvent,而trackEvent本身管理了自己的依赖。代码结构更清晰。
缺点:多一层useCallback,对新手来说可能更容易搞混。而且大多数开发者用useCallback的方式都是错的(忘记加依赖)。
诚实的话:大多数时候,你不需要useCallback。方法1更简单、更容易理解、更难出错。
千万别干的一件事:禁用ESLint警告
你肯定在各种教程和项目里看过这样的代码:
// eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { onInit(); }, []);这一行注释是什么意思?**"请忽视ESLint的警告,我知道我在干什么。"**
听起来很自信?实际上,这是最危险的做法。
为什么说"禁用"等于"埋地雷"?
ESLint的这条规则是React官方维护的。它能自动检测:
✓ 你在effect里用了某个值,但没写在依赖数组里(→ stale closure)
✓ 依赖数组里有没被用过的值(→ 浪费性能)
✓ 依赖项少了(→ 数据不同步)
一旦禁用,这些检查全部失效。你用"我觉得没问题"来赌博。
真实伤害
想象一个场景:你在一个电商网站的商品详情页工作。用户可以:
进入商品A的详情页
切换到商品B的详情页
继续操作
但你写的埋点effect是这样的:
// ❌ 常见的错误写法 useEffect(() => { // 当用户进入详情页时,记录一次"商品浏览"事件 const productId = product.id; const userId = user.id; sendAnalytics({ event: 'view_product', productId, userId, timestamp: Date.now() }); }, []); // ← 没有任何依赖问题来了:用户从商品A切换到商品B时,effect不会重新运行。所以系统记录的都是"商品A"的浏览数据。
结果:
你的商品浏览统计全是错的
产品运营看着这些假数据做决策
可能花几周才发现数据不对劲
此时数据库里已经存了几百万条错误记录
有人想修复这个bug,把product.id加到依赖里:
useEffect(() => { const productId = product.id; sendAnalytics({ event: 'view_product', productId, userId: user.id, timestamp: Date.now() }); }, [product.id, user.id]); // ← 现在对了但如果有人不理解,继续写成这样怎么办?
// eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { // ... 同样的bug ... }, []);他"压制"了ESLint的警告,假装没问题。殊不知这行注释就像在代码里埋了一枚地雷。
React 19的新方案:useEffectEvent
React 19.2新增了一个hook,专门为了解决"我想在mount时运行一次,但又要用最新的数据"这个问题。
叫useEffectEvent。
const onInitEvent = useEffectEvent(() => { // 这里能访问最新的props和state initializeData(); }); useEffect(() => { onInitEvent(); }, []); // ← 依赖数组空着没关系,因为用了useEffectEventuseEffectEvent是什么鬼?
用一个比喻:useEffectEvent创建了一个**"通道"**。
通道本身是稳定的(不会改变)
但通道里传来的信息总是最新的
所以你可以:
把函数放在effect里使用,而不用担心它导致重新运行
同时还能访问到最新的state和props
简单来说
如果你写的是:
// ❌ 老办法:禁用ESLint // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { onInit(); }, []); // ✅ 新办法:用useEffectEvent const onInitEvent = useEffectEvent(onInit); useEffect(() => { onInitEvent(); }, []);两个都能"工作",但第二个是官方认可的、安全的、不会埋地雷的做法。
如果你用React 19+,这应该是你的默认选择。
最根本的问题:你对effect的理解可能反了
现在,让我说最直白的话。
很多人把useEffect看成了"生命周期钩子"。比如:
"我想在组件刚加载时运行一段代码"
"我想在某个prop变化时更新数据"
"我想在卸载前做清理"
这个思路来自于class component时代的componentDidMount、componentDidUpdate。
但function component完全不同。
重新理解effect
effect的真实意义不是"在某个时刻运行代码",而是**"声明某个同步关系"**。
比如,不要这样想:
// ❌ 错误的思维:运行代码 // "当userId变化时,获取新数据并更新" useEffect(() => { fetchUserData(userId); // 获取数据 }, [userId]);而要这样想:
// ✅ 正确的思维:声明同步 // "保持UI中的userList数据和userId的同步" useEffect(() => { fetchUserData(userId); }, [userId]);看似一样,但思维方式完全不同。
前者会让你困惑:"那如果userId变了,effect什么时候运行?""运行多少次?"
后者就很清晰:"userId和userList需要保持一致。如果userId变了,userList就过期了,需要重新获取。"
用React 18+的思维
从React 18开始,理解effect的最好方式是:
effect是一个"同步声明",不是"命令式的操作"。
✅ "保持xxx和yyy同步"
✅ "当这些值变化时,我需要重新执行这个操作"
❌ "在mount时运行这段代码"
❌ "每次render都运行这段代码"
写effect前必问自己的6个问题
1. 我真的需要effect吗?
很多时候,你根本不需要effect。比如:
// ❌ 不需要effect useEffect(() => { setFiltered(items.filter(i => i.status === 'active')); }, [items]); // ✅ 直接算,不用effect const filtered = items.filter(i => i.status === 'active');什么时候不需要effect:
处理props或state的数据(直接在render里算)
根据其他值计算新值(用useMemo)
没有"副作用"的纯逻辑
什么时候需要effect:
获取数据(API请求)
订阅事件监听器
启动定时器
和非React系统同步(比如原生DOM API)
2. 我用到的值都写在依赖里了吗?
检查一下effect内部用到的所有值:
useEffect(() => { // 这里用了:userId, theme, isDarkMode updateUserPreferences(userId, theme, isDarkMode); }, [userId, theme, isDarkMode]); // ← 都在这儿吗?如果某个值用了但没写依赖,ESLint会警告你。相信它,别禁用。
3. 我是不是在制造数据过期的bug?
如果effect读取一个会变化的state,那这个state就应该在依赖里:
// ❌ 危险 useEffect(() => { const onClick = () =>console.log(count); // 用到了count button.addEventListener('click', onClick); }, []); // ← count没写,会一直是初始值 // ✅ 安全 useEffect(() => { const onClick = () =>console.log(count); button.addEventListener('click', onClick); return() => button.removeEventListener('click', onClick); }, [count]); // ← count在这儿4. 这个函数非得在effect外面定义吗?
大多数时候,答案是"不需要"。直接在effect里写逻辑更简单:
// 不必要地复杂 const trackEvent = () => { /* ... */ }; useEffect(() => { trackEvent(); }, [trackEvent]); // 更简单 useEffect(() => { // 直接在这里写逻辑,不用建函数 console.log("user active"); }, []);5. 我有没有禁用ESLint?
如果你的代码里有// eslint-disable-next-line react-hooks/exhaustive-deps,停下来。
不要禁用。改你的代码,让ESLint满意。
6. 这是不是"只在mount时运行"的情况?
如果是,并且你用React 19+,用useEffectEvent:
const initEvent = useEffectEvent(initializeApp); useEffect(() => { initEvent(); }, []);不用禁用ESLint,代码更安全。
真实场景:为什么这个bug特别难被发现
让我讲一个很常见的bug案例。
某个电商网站的商品详情页,有一个埋点系统用来追踪"用户浏览了哪些商品"。
开发者写的effect是这样:
useEffect(() => { // 用户进入这个商品页面时,发送一个埋点事件 const productId = product.id; const userId = user.id; sendAnalytics({ event: 'product_view', productId, userId, timestamp: Date.now() }); }, []); // ← 空依赖数组表面上看没问题。"进入详情页时发一条埋点",逻辑没毛病。
问题出现了
但现实中,用户不仅仅是"进入一个商品页面就离开"。用户会:
点开商品A的详情页
看了一会儿,然后点击"换个看看"
被重定向到商品B的详情页
继续浏览
这整个过程中,组件没有被卸载重装。只是product的值变了。
但effect不会重新运行,因为依赖数组是空的。
所以系统记录的埋点数据全是:第一个进来的那个商品。
为什么bug难被发现?
在本地开发时:
打开商品A,数据是对的
切换到商品B,数据还是显示的A
但你在开发工具里看不出来
你得:
打开网络调试工具
查看埋点的API请求
对比productId是不是不对
很多开发者根本不会这么仔细测试。"在本地试了一遍,没发现bug"。
真实伤害
等到上线了,几百万用户开始用你的网站。数据库里积累了几百万条错误的浏览记录。
数据分析团队花了一两周才发现"奇怪,商品的浏览数据全不对"。
此时,该做的决策已经基于假数据做了。库存、推荐、营销活动——都用了错的数据。
修复方案
就是把变化的值加到依赖里:
useEffect(() => { sendAnalytics({ event: 'product_view', productId: product.id, userId: user.id, timestamp: Date.now() }); }, [product.id, user.id]); // ← 依赖也要改现在,每当product.id变化,effect就重新运行,发送正确的埋点。
就这么简单。
但这个"简单"的修复,得靠开发者理解useEffect的规则才能想到。很多人就因为不理解,一直在制造线上bug。
effect的工作流程(用流程图理解)
┌──────────────────────────────────────────────┐ │ 1. 组件第一次render (mount) │ ├──────────────────────────────────────────────┤ │ • 取最新的props/state快照 │ │ • 执行effect函数体 │ │ • "记住"依赖数组:[dep1, dep2] │ └────────────┬─────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────┐ │ 2. 组件re-render(但没有mount) │ ├──────────────────────────────────────────────┤ │ • 检查依赖数组是否变化 │ │ ├─ 没有变化?→ 什么都不做 │ │ └─ 有变化? → 运行cleanup,然后重新运行 │ │ effect │ └────────────┬─────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────┐ │ 3. 组件unmount │ ├──────────────────────────────────────────────┤ │ • 执行cleanup函数(如果有的话) │ │ • 清理事件监听、定时器等 │ └──────────────────────────────────────────────┘关键点:effect看到的是render时刻的值。如果依赖没变,effect就不运行,闭包就"冻结"了。
常见问题(都是真实的困惑)
Q1: 为什么依赖数组里的东西这么多?
useEffect(() => { // ... }, [userId, postId, theme, language, isDarkMode]);A:这说明你的effect在做太多事。React的最佳实践是:一个effect做一件事。
拆分成多个小effect:
// effect 1: 获取文章 useEffect(() => { fetchPost(userId, postId); }, [userId, postId]); // effect 2: 应用主题 useEffect(() => { applyTheme(isDarkMode, language); }, [isDarkMode, language]);Q2: 能不能在effect里用async?
// ❌ 不行 useEffect(async () => { const data = await fetch('/api/data').then(r => r.json()); setData(data); }, []);A:不能。effect的回调必须是普通函数或返回cleanup函数。改成这样:
// ✅ 正确做法 useEffect(() => { (async () => { const data = await fetch('/api/data').then(r => r.json()); setData(data); })(); }, []);或者更常见的做法(避免race condition):
useEffect(() => { let ignore = false; const fetchData = async () => { const data = await fetch('/api/data').then(r => r.json()); if (!ignore) setData(data); // 组件如果卸载了,就不更新 }; fetchData(); return() => { ignore = true; }; // cleanup:标记为忽略 }, []);Q3: 怎么判断依赖对不对?
A:最简单的方式:打开ESLint,让它告诉你。
useEffect(() => { console.log(count); // ← 用到了count }, []); // ESLint会说:"count缺少依赖"ESLint不会出错。相信它。
Q4: 为什么要写cleanup函数?
useEffect(() => { const id = setInterval(() => { // ... }, 1000); return () => clearInterval(id); // ← 这是cleanup }, []);A:两个原因:
防止内存泄漏:如果组件卸载了,定时器还在运行,会浪费内存
防止多个定时器叠加:如果effect重新运行,新的定时器启动前,旧的必须被清除
想象一下,如果没有cleanup,你的组件render了100次,就会有100个定时器同时运行。各种bug随之而来。
三条铁律,记住它们
1. 用到的值一定要加到依赖数组里
这不是"最佳实践",不是"建议"。这是规则。
如果你用了某个值但没写在依赖里,你就是在制造bug。即使看起来一时没问题,也是定时炸弹。
2. effect读到的值是"快照",不是"实时更新"
每次组件render,React都会创建新的props和state。effect看到的是那一时刻的值。
如果你想要最新的值,就得把这个值加到依赖里,让effect重新运行。
3. 不要禁用ESLint规则
如果你的代码需要禁用react-hooks/exhaustive-deps,说明你的代码有问题,而不是规则有问题。
修复代码,不要压制警告。或者用useEffectEvent。
说了这么多,总结一下
✅好的effect:
清晰地列出所有依赖
写了cleanup函数(如果需要)
没有ESLint警告
代码的逻辑和依赖对得上
❌坏的effect:
空依赖数组,但effect内部用了会变化的值
没有cleanup函数
// eslint-disable...基于"我觉得没问题"的想象
最后的建议
读一遍React官方的useEffect文档。不是别人的博客,不是视频教程,就是官方文档。
然后,下次写effect时,在你的脑海里过一遍这6个问题。
如果你能做到这两点,你的React代码质量会有明显提升。线上bug会少很多。你的队友会感激你。
觉得有帮助?欢迎关注《前端达人》,我们持续输出React、Web API、前端架构等硬核内容。点赞、分享给身边的开发伙伴吧,让大家一起告别useEffect的坑!
.cls-1{fill:#001e36;}.cls-2{fill:#31a8ff;}
.cls-1{fill:#001e36;}.cls-2{fill:#31a8ff;}