React setState 连续调用「丢更新」排查:批量更新与函数式更新

React setState 连续调用「丢更新」排查:批量更新与函数式更新

写 React 时你大概率遇到过这种诡异现象:一个按钮里连着调三次 setCount(count + 1),点一下却只加了 1,不是 3。或者刚 setState 完立刻读 state,值还是旧的。这不是 React 的 bug,而是「批量更新」和「闭包快照」两个机制在起作用。搞懂它们,这类问题就再也不会困扰你。

一、复现「加 3 变加 1」

jsx 复制代码
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 直觉以为会 +3,实际只 +1
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  return <button onClick={handleClick}>count = {count}</button>;
}

点一下,count 从 0 变成 1,不是 3。为什么?

二、根因:count 是这次渲染的「快照」,三次算的是同一个值

关键点:count 不是一个会实时变化的变量,而是本次渲染被「冻结」的值。 在这次点击的事件函数里,count 自始至终都是 0。所以三行代码实际上是:

jsx 复制代码
setCount(0 + 1); // 请求把 count 设成 1
setCount(0 + 1); // 请求把 count 设成 1
setCount(0 + 1); // 请求把 count 设成 1

三次都是「把 count 设成 1」,React 又把同一次事件里的多个 setState 合并成一次渲染(这就是批量更新 / batching),最终结果自然是 1。

顺带解决另一个高频困惑------setState 后立刻读不到新值:

jsx 复制代码
const handleClick = () => {
  setCount(count + 1);
  console.log(count); // 打印的还是 0!不是 1
};

因为 count 是快照,setCount 只是「预约下次渲染用新值」,不会就地改掉当前这个 count 变量。想拿新值,得等下一次渲染。

三、正确写法:用函数式更新

新值依赖旧值时,别传具体值,传一个函数。React 会把这些函数排队,依次拿「上一个的结果」当入参:

jsx 复制代码
const handleClick = () => {
  // updater 收到的 prev 是队列里累积后的最新值
  setCount((prev) => prev + 1); // 0 -> 1
  setCount((prev) => prev + 1); // 1 -> 2
  setCount((prev) => prev + 1); // 2 -> 3
};

这次点一下就是 +3。记住判断标准:新 state 依赖旧 state 时,一律用 setX(prev => ...),不要用 setX(当前变量 + 1)

四、React 18 之后,异步回调里也批量了

React 17 有个坑:只有 React 事件(onClick 等)里的 setState 才批量,setTimeoutfetch().then 这类异步回调里的 setState 是逐个触发渲染的,连着 set 三个 state 会渲染三次。

React 18 引入自动批量(automatic batching),异步回调里也统一批量了:

jsx 复制代码
const load = async () => {
  const res = await fetch("/api/data");
  const data = await res.json();

  // React 18:下面两个 setState 合并成一次渲染
  setLoading(false);
  setData(data);
  // React 17:这里会渲染两次
};

这是升级 React 18 后行为变化的常见来源。绝大多数情况批量是好事(少一次渲染),个别真需要「立即单独刷新」的场景,可以用 flushSync 强制同步:

jsx 复制代码
import { flushSync } from "react-dom";

flushSync(() => setLoading(false)); // 立刻渲染,不与后面合并
doSomethingThatReadsDOM();

flushSync 是逃生舱,别滥用------它会牺牲批量带来的性能优势。

五、对象/数组 state 的连带坑

同样的快照逻辑,套在对象上还多一个「必须换新引用」的坑:

jsx 复制代码
const [user, setUser] = useState({ name: "", age: 0 });

// 错误:直接改属性,引用没变,React 认为没更新,不重渲染
const bad = () => {
  user.age = 18;
  setUser(user);
};

// 正确:展开出新对象,基于 prev 计算
const good = () => {
  setUser((prev) => ({ ...prev, age: 18 }));
};

要点:React 用 Object.is 比较新旧引用来决定要不要重渲染,原地修改属性引用不变,界面就不刷新。始终返回新对象

小结

  • state 是每次渲染被冻结的快照 ,同一个事件里 count 始终是旧值,setState 后立刻读也是旧值。
  • 新值依赖旧值就用函数式更新 setX(prev => ...) ,别用 setX(变量 + 1) 连写。
  • React 18 起异步回调也自动批量;需要立即单独渲染用 flushSync(逃生舱,慎用)。
  • 对象/数组 state 必须返回新引用,原地改属性不会触发重渲染。

一句话记住:setState 不是「立刻改变量」,是「预约下次渲染的值」;依赖旧值时永远用函数式更新。

相关推荐
程序员黑豆5 小时前
Java 注释详解:单行、多行与文档注释的完整指南
java·前端·ai编程
剪刀石头布啊5 小时前
antd中可编辑表格中巧用useWatch实现联动高性能效果,以及定制延伸学习
前端
油丶酸萝卜别吃5 小时前
前端转全栈学习路线
前端·学习
浮生望6 小时前
前端路由进阶:History API原理与手写HistoryRouter
前端
陆枫Larry6 小时前
JavaScript 中的竞态是什么,为啥会有竟态?
前端
上海安当技术8 小时前
半天接入:USBKey RESTful API + C 动态库,Web 和 C/S 两套集成路径实战
前端·后端·restful·集成·usbkey
DevUI团队9 小时前
从“即兴创作”到“规格先行”,华为云码道(CodeArts)代码智能体持续深耕企业级规范驱动开发能力
前端·人工智能·后端
weixin_431600449 小时前
NestJS 入门(3):Guard 如何挡住未登录请求?
前端·后端·学习·nest.js
avi911110 小时前
[AI教做人]AI平台做2项目;一个3D模型展示,另一个框架多人
javascript·人工智能·ai·3d模型·3d引擎·顶点和法线
kyriewen10 小时前
我用Claude Code两天干完了团队两周的排期——周报发出去那一刻我就后悔了
前端·javascript·ai编程