闭包陷阱:为什么你的 Hooks 总是拿到旧的值?
从“为什么 useEffect / setInterval / 事件回调里总拿到旧 state”这个高频 Bug 出发,讲清渲染快照(snapshot)+ 闭包捕获的根因;系统对比 4 种解决方案:依赖数组、函数式更新、useRef、事件回调模式,并给出选择准则
这是 Hooks 时代最经典、也最“气人”的 bug:
useEffect里打印的永远是旧值setInterval一直用初始 state- 事件回调里拿到的 props/state “滞后”
你可能会怀疑:React 是不是没更新?Hooks 是不是有坑?
其实问题不在 Hooks,而在 JavaScript 的一个基础特性:闭包,叠加 React 的一个核心模型:每次渲染都是快照(snapshot)。这一篇我们把这件事讲到“你能推理出来”的程度:
看到旧值,你就知道它来自哪一次渲染;
知道来源,你就能选对解决方案。
0. 先给你一句总纲:闭包拿旧值不是“读错了”,而是“你捕获的是旧快照”
在函数组件中:
- 组件函数每次执行 = 一次渲染
- 这次渲染中的 props/state 是固定的快照
- 你在这次渲染里创建的函数(回调)会闭包捕获这份快照
所以当你把这个函数交给:
setIntervaladdEventListener- Promise/异步回调
- 某个只初始化一次的 effect
它就会持续引用那次渲染的变量。
不是 React 不更新,而是你手里的函数没更新。
1. 复现:为什么 setInterval 永远打印 0?
function Demo() {
const [count, setCount] = React.useState(0)
React.useEffect(() => {
const id = setInterval(() => {
console.log(count)
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <div>{count}</div>
}
你会看到:
console.log(count)永远是 0(或一直滞后)setCount(count + 1)也可能只把它加到 1 就停
1.1 发生了什么?
关键点有两个:
useEffect(..., [])只在首次渲染后执行一次- interval 回调闭包捕获的是“首次渲染”的
count = 0
之后 count 在 UI 上更新了,但:
- effect 没重跑
- interval 回调函数也没被替换
- 闭包仍然指向旧快照
2. 闭包陷阱的本质:三件事同时成立就会中招
你可以用一个“判定公式”快速识别:
只要同时满足:
- 你在某次渲染里创建了一个回调函数
- 这个回调在未来某个时刻执行(异步/订阅/计时器/事件)
- 这个回调使用了 props/state 这类渲染变量
就可能出现 stale closure(过期闭包)。
3. 解决方案总览:4 条路,选对一条就行
我们先列答案,再讲选择:
- 把变量放进依赖数组(让 effect 重建回调)
- 用函数式更新(让更新不依赖闭包里的旧 state)
- 用 useRef 保存最新值(让回调读 ref 而不是读快照)
- 把“事件回调”做成稳定函数 + 内部读取最新值(一种工程化模式)
4. 方案 1:依赖数组(让 effect 绑定到变化,自动重建)
把 count 加进依赖:
React.useEffect(() => {
const id = setInterval(() => {
console.log(count)
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count])
优点
- 语义清晰:这个 effect 依赖 count
- 闭包永远捕获最新 count
缺点(很关键)
-
每次 count 变化都会:
- 清理旧 interval
- 重新创建新 interval
-
对一些“订阅类副作用”来说,频繁重建可能不理想(成本高、抖动、丢事件)
适用场景:
- effect 本来就应该随着依赖变化而重建
- 重建成本可接受
5. 方案 2:函数式更新(最常用,也最推荐的“解旧值”方式)
如果你的目的只是“在旧值基础上更新”,不要读闭包里的 count,而是让 React 把最新值交给你:
setCount((c) => c + 1)
配合 interval:
React.useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
为什么这能解决?
因为更新函数 c => c + 1:
- 不依赖外层闭包里的 count
- React 在执行更新时,会传入“当时最新的 state”
适用场景:
- 你需要基于旧 state 计算新 state(加一、累加、push、toggle)
- 你不关心回调里读取最新值做别的逻辑(只关心更新)
6. 方案 3:useRef 保存最新值(订阅不重建,但读取永远最新)
当你既想:
- effect 只执行一次(不频繁重建订阅/计时器)
- 回调里又要读到最新 state/props
就用 ref:
function Demo() {
const [count, setCount] = React.useState(0)
const countRef = React.useRef(count)
React.useEffect(() => {
countRef.current = count
}, [count])
React.useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current) // 永远最新
}, 1000)
return () => clearInterval(id)
}, [])
return <div>{count}</div>
}
优点
- 订阅/计时器只建立一次
- 回调读到的永远最新
缺点
- ref 更新不会触发渲染(它是“逃生通道”)
- 过度使用 ref 会让数据流变得不透明(可维护性下降)
适用场景:
- WebSocket / event emitter / interval 这类“长期存在的外部系统”
- 不希望频繁重建订阅
- 回调需要读最新值做逻辑判断
7. 方案 4:稳定回调模式(把“最新读取”封装起来,工程里很常用)
很多团队会把“读取最新值”封装成一个可复用的 hook,例如:
useLatest:把值放到 ref 里useEvent/ “稳定事件回调”:返回一个引用稳定但内部总读最新的函数
概念上长这样:
function useLatest(value) {
const ref = React.useRef(value)
React.useEffect(() => {
ref.current = value
}, [value])
return ref
}
function useStableCallback(fn) {
const fnRef = useLatest(fn)
return React.useCallback((...args) => fnRef.current(...args), [])
}
使用:
const onMessage = useStableCallback((msg) => {
// 这里读取到的 state/props 永远是最新的(通过 fnRef 间接获取)
})
注意:上面是一种“模式示意”。是否采用、如何实现要结合团队规范与 React 版本(并发语义)谨慎评估。
适用场景:
- 你有大量事件回调/订阅回调
- 不想每次依赖变化都换函数引用(避免 re-subscribe、避免子组件重渲染)
- 希望把“防 stale closure”变成一种通用设施
8. 如何选择?给你一个实战决策树
你可以按下面 3 问快速选方案:
Q1:我只是基于旧 state 计算新 state 吗?
- 是 → 函数式更新(方案 2)
- 否 → 看 Q2
Q2:这个 effect/订阅需要随着某些值变化而重建吗?
- 需要 → 依赖数组(方案 1)
- 不需要 → 看 Q3
Q3:我需要回调里读取最新 state/props 吗?
- 需要 → useRef / 稳定回调模式(方案 3 / 4)
- 不需要 → 维持现状或用函数式更新即可
9. 常见误区:为了解旧值,删依赖数组(这是把症状藏起来)
你可能见过这样的“修复”:
// eslint 警告依赖不全
// 于是你把依赖数组改成 []
useEffect(() => {
doSomething(count)
}, [])
这通常会把问题变得更隐蔽:
- effect 绑定了旧快照
- 逻辑悄悄失效
- bug 可能只在某些状态组合出现
正确做法是:
- 要么补齐依赖
- 要么改变代码结构(函数式更新/ref)
- 要么把 effect 拆小,明确每段逻辑的依赖
10. 本文小结:把“旧值”当成一个信号
当你在 Hooks 里看到“旧值”,不要慌,把它当成提示:
- “这段代码运行在旧渲染的快照里”
- “我创建的回调没随着状态变化而更新”
- “我需要重新绑定 effect,或者不要依赖闭包”
你需要记住的三句话:
- 每次渲染是快照
- 闭包会捕获快照
- 解决 stale closure 的本质是:让回调读到“最新来源”(依赖重建 / 函数式更新 / ref)
留言讨论
Discussion
欢迎交流与反馈