React 状态更新的异步陷阱:为什么 setState 后立刻读还是旧值?从批处理到函数式更新
很多刚写 React 的同学都会撞上同一个问题:点一次按钮,count 明明 setCount(count + 1) 了,紧接着 console.log(count) 打印出来的却还是旧值。更迷惑的是,有时在 setTimeout 里写同样的代码,打印的又是新值。这不是 React 的 bug,而是状态更新机制本身的特性:状态是快照,更新是调度,不是立刻赋值。
一、先建立正确的模型:一次渲染就是一张快照
React 的函数组件每次渲染都会拿到一组固定的 props 和 state。你可以把一次渲染理解成拍了一张照片:照片里的 count 是 0,那么这次渲染里所有用到 count 的地方——JSX、事件处理函数、闭包——看到的都是 0。调用 setCount(1) 并不会改写这张照片,它只是告诉 React:请用新值再拍一张。
所以下面这段代码的输出一定是 0,而不是 1:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // 0:这次渲染的快照里 count 就是 0
};
return <button onClick={handleClick}>点击:{count}</button>;
}
理解了“快照”,很多怪现象就通了:事件处理函数里读到的永远是它所属那次渲染的值;想在更新后立刻拿到新值,正确的做法不是去“读 state”,而是直接用你已经算出来的新值,或者把依赖新值的逻辑放到 effect 里。
二、为什么连续调用三次只加了 1?批处理在起作用
再看一个经典坑:
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
如果 count 初始是 0,点一次后结果是 1,不是 3。因为三次调用都基于同一次渲染的快照,算出来的都是 0 + 1。React 还会把同一事件里的多次更新合并成一次渲染,这就是批处理(batching)。批处理是性能优化:一次点击触发的多次 setState 只重渲染一次,避免界面闪烁和重复计算。
React 18 之后,批处理覆盖了几乎所有场景,包括 setTimeout、Promise 回调和原生事件。过去“setTimeout 里是同步更新”的经验在 React 18 里已经不适用了,不要再靠它来判断同步还是异步。
三、正确做法一:函数式更新
当新状态依赖旧状态时,永远用函数式更新:把“如何算”交给 React,而不是自己先算好一个值:
const handleClick = () => {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};
这三次调用排成一个更新队列,React 会按顺序执行:0 → 1 → 2 → 3。函数式更新还有一个好处:即使事件处理函数是旧闭包,prev 拿到的也是最新的排队值,不会被快照困住。
经验法则很简单:setState 的参数里只要出现了当前 state 变量,就改成函数形式。setCount(count + 1) 改成 setCount((prev) => prev + 1),setList([…list, item]) 改成 setList((prev) => […prev, item])。这条规则能避掉八成以上的状态更新 bug。
四、正确做法二:更新后的副作用放进 useEffect
如果你需要在 count 变化后发请求、写 localStorage、更新 document.title,不要写在 setCount 的下一行,要写在 effect 里,并把 count 放进依赖数组:
useEffect(() => {
document.title = `点击了 ${count} 次`;
// 这里的 count 一定是渲染后的新值
}, [count]);
effect 在提交新渲染之后运行,读到的就是新快照。这也是 React 推荐的数据流:事件里只负责“请求一次更新”,渲染结果变化后的连锁反应交给 effect。
五、两个容易踩的进阶坑
第一,对象和数组状态要整体替换,不要原地修改。setUser(user.name = '新名字') 不会触发重渲染,因为引用没变;正确写法是 setUser((prev) => ({ …prev, name: ‘新名字’ }))。数组同理,先复制再改:setList((prev) => prev.map((x) => (x.id === id ? { ...x, done: true } : x)))。
第二,闭包陷阱在定时器里最常见。setInterval 回调捕获的是创建那次渲染的 state,之后 state 再变,回调里读到的还是旧值。解法有三种:用函数式更新(只依赖旧值时最干净)、把值存进 ref(useRef 每次渲染都同步更新)、或者在 effect 里根据依赖重建定时器。选哪种取决于你是“只想更新”还是“确实要读最新值做判断”。
六、总结一张检查表
写 React 状态逻辑时,遇到结果不对先按这张表自查:第一,setState 后立刻读 state 是不是旧快照;第二,新值是否依赖旧值,是就改函数式更新;第三,连续多次更新是不是都基于同一个快照;第四,更新后的副作用是不是写在了事件里而不是 effect 里;第五,对象/数组是不是原地修改导致引用没变。
记住一句话就够了:state 不是变量,是渲染的快照;setState 不是赋值,是向 React 提交一次新的渲染请求。按这个模型写代码,React 的“异步”就不再是玄学,而是一套可预测的规则。
- 点赞
- 收藏
- 关注作者
评论(0)