1. 从一道计数器问题说起setState 为什么总慢半拍先从一个最典型的场景开始。很多同学刚接触 React 时都写过类似这样的代码const [count, setCount] useState(0); const handleClick () { setCount(count 1); setCount(count 1); setCount(count 1); };满心期待点击一次按钮count 从 0 变成 3。实际运行发现count 只变成了 1。又或者你在setState之后立刻读取这个状态const handleClick () { setCount(count 1); console.log(count); // 打印的还是旧值 };打印出来的竟然还是原来的值。这背后的原因就是 React 的状态更新机制并不是调用即生效那么简单。它涉及到异步调度、批处理、闭包捕获等一系列概念而很多人踩坑都是因为没搞清楚这一层机制。这篇文章就把 React 中更新 State 的正确姿势完整梳理一遍。内容适合三类人刚接触函数组件和 Hooks 的新手、写过一段时间但偶尔被状态不更新问题卡住的初中级开发者以及需要在团队里做代码评审、想给同事讲清楚这块原理的人。我会从原理讲到实操再讲到常见的坑和排查思路尽量让每个问题都有明确结论和可执行的写法。这里先给出一个核心认知在 React 中setState的本质是触发一次重新渲染的请求而不是同步修改变量。理解这一点后面所有的问题都会迎刃而解。2. 批处理机制React 为什么要把多个更新攒在一起处理2.1 从一次点击触发五次渲染到只渲染一次在 React 18 之前如果你在同一个事件处理函数里连续调用多个setStateReact 会把这些更新放进同一个队列里然后统一处理、合并最终只触发一次渲染。这就是所谓的批处理Batching。回到开头的例子const handleClick () { setCount(count 1); setCount(count 1); setCount(count 1); };这里三次setCount(count 1)中的count来自当前这次渲染的闭包都是同一个值比如 0。React 把它们合并成一个更新最终执行count从 0 变成 1而不是 0 → 1 → 2 → 3。这个设计其实很合理。如果每一次setState调用都立刻触发渲染那么一个事件处理函数里有五次更新就会触发五次渲染浏览器要不停地执行协调和 DOM 渲染工作性能会非常差。批处理相当于把你的多次需求合并成一次执行总体的渲染负担小得多。2.2 React 18 的自动批处理范围扩大了React 18 之前批处理只在 React 事件处理函数比如onClick、onChange中生效。如果你在setTimeout、Promise回调、原生事件监听器里调用setStateReact 并不会自动批处理每次调用都会独立触发一次渲染。React 18 引入了createRoot同时带来了自动批处理Automatic Batching。现在无论你在哪里调用setState——事件处理函数、定时器回调、Promise 回调、原生事件监听器——React 都会自动批处理这些更新。这是一次很大的体验提升你不需要再像以前那样手动做任何配置。// React 18 中下面的 Promise 回调里的更新也会自动批处理 fetch(https://api.example.com/data) .then(res res.json()) .then(data { setLoading(false); setData(data); // 两次更新会被批量处理只触发一次渲染 });这在大多数情况下是好事但也带来了一个新的坑如果你依赖上一次更新之后的立即渲染结果来做下一步判断那么自动批处理会打破你的预期。举个例子setPage(1); scrollToTop(); // 你希望 setPage 之后滚动到顶部但此时 DOM 还没更新这种情况下scrollToTop执行时 React 还没完成重新渲染页面位置不会变化。正确做法是用useEffect监听page的变化再执行滚动而不是在setState之后同步操作。2.3 需要手动绕开批处理怎么办flushSync虽然批处理是推荐行为但有些场景确实需要立即强制更新。React 18 专门提供了一个 APIflushSync。import { flushSync } from react-dom; const handleClick () { flushSync(() { setPage(1); }); // 此时 DOM 已经同步更新完成 scrollToTop(); };flushSync会强制 React 同步执行更新但这种用法是破坏性的它会打断渲染的优先级调度能不用就不用。我自己的经验是大多数需要立即拿到更新结果的场景本质上都应该重新设计数据流而不是硬用flushSync。比如滚动位置这种副作用完全可以放到useEffect里由状态变化驱动。提示flushSync的使用场景非常有限日常业务开发里基本不会用到。如果你的代码里频繁出现flushSync大概率是状态设计出了问题而不是 React 有问题。3. 函数式更新解决旧值捕获问题的标准答案3.1 为什么连续更新需要传函数前面说了同一个渲染周期内多次调用setState(count 1)count始终是同一个闭包值。那如果我真的想连续加 3 次每次基于上次结果再加 1呢答案是使用函数式更新setCount(prev prev 1); setCount(prev prev 1); setCount(prev prev 1);React 在处理函数式更新时会把上一次执行的结果作为参数传给下一个函数所以这里的执行过程是第一次prev是 0返回 1第二次prev是 1返回 2第三次prev是 2返回 3最终count变成 3。这个特性很有用但很多新手容易忽略。比较典型的场景包括计数器连续加减、购物车数量递增、点赞取消点赞等等。只要新的状态依赖之前的状态就应该优先考虑函数式更新。3.2 函数式更新的另一种用途避免闭包过期除了连续更新的场景函数式更新还能帮你规避闭包问题。const [count, setCount] useState(0); const handleClick () { setTimeout(() { setCount(count 1); }, 1000); };这里setTimeout回调捕获的是这一轮渲染时的count。如果你在 1000ms 内多次点击回调里的count都是同一个旧值点击三次最终也只是从 0 变成 1。改成函数式更新就安全了setTimeout(() { setCount(prev prev 1); }, 1000);这时候无论回调什么时候执行它拿到的prev都是最新的值三次点击最终会让count从 0 变成 3。3.3 什么时候用函数式、什么时候用直接值这是一个经常被问的问题。我整理了一个简单的判断表场景推荐写法原因新状态与旧状态无关setCount(10)传具体值就够了代码更清晰新状态依赖旧状态setCount(prev prev 1)避免闭包过期和连续更新的问题在事件或定时器等异步环境中更新setCount(prev ...)不确定当前闭包是否最新函数式最安全同一函数内连续多次更新状态setCount(prev ...)保证每次基于上一次结果计算总结一句话只要心里有一丝这个新值可能依赖上一个旧值的念头就直接用函数式更新。这种做法开销极小但能规避一大类难以排查的 bug。4. 不可变更新为什么你不能直接修改对象和数组4.1 React 的更新判断逻辑引用比较另一个高频坑是直接修改引用类型的内部值然后自以为状态已经更新了。比如const [user, setUser] useState({ name: 张三, age: 18 }); const handleModify () { user.age 19; // 直接修改对象属性 setUser(user); // 传的还是同一个对象引用 };先在useState钩子中定义了一个user对象然后在事件处理函数里直接修改该对象的age属性再调用setUser并传入同一对象引用。这段代码的问题在于React 在比较新旧状态时会使用Object.is进行引用比较由于新旧引用完全相同Object.is将判定状态未变化从而跳过渲染导致界面不会更新。正确的写法是创建新对象const handleModify () { setUser({ ...user, age: 19, }); };使用展开运算符创建了一个新对象同时复制了原有属性并覆盖了希望修改的字段确保传入setUser的是全新引用。这样 React 通过引用比较便能识别到状态变化从而触发重渲染。数组的操作同理。很多刚接触 React 的同学会写出类似代码const [list, setList] useState([1, 2, 3]); // 错误写法 const addItem (item) { list.push(item); // 直接修改原数组 setList(list); }; // 正确写法 const addItem (item) { setList([...list, item]); // 创建新数组 };这里的关键逻辑是React 通过引用是否变化来判断状态是否变更而不会主动做深比较。这是 React 为了性能做的权衡——浅引用比较代价极低而深比较在大对象、大数组上代价高且不确定。4.2 正确操作数组和对象的方法清单整理一份常见的不可变更新操作对照表方便日常查阅操作错误写法正确写法新增对象属性obj.newKey value; setObj(obj);setObj({ ...obj, newKey: value });修改对象属性obj.key value; setObj(obj);setObj({ ...obj, key: value });删除对象属性delete obj.key; setObj(obj);const { key, ...rest } obj; setObj(rest);数组追加元素arr.push(item); setArr(arr);setArr([...arr, item]);数组删除元素arr.splice(idx, 1); setArr(arr);setArr(arr.filter((_, i) i ! idx));数组修改元素arr[idx] val; setArr(arr);setArr(arr.map((item, i) i idx ? val : item));这里有个容易被忽略的细节使用...展开语法做浅拷贝时嵌套层级越深越容易出问题。比如const [user, setUser] useState({ name: 张三, address: { city: 杭州, district: 西湖区, }, }); // 修改嵌套的 address.city setUser({ ...user, address: { ...user.address, city: 滨江区, }, });注意你不能只展开最外层user还必须展开内层的address来创建新的内层对象。因为浅拷贝只复制了第一层引用如果内层对象被直接修改之前的引用比较仍然会发现该内层对象引用未变。当 React 后续对该引用执行比较或深层操作时问题才会暴露。如果嵌套层级非常深每层都要逐一展开代码会变得冗长。这时候可以考虑引入结构克隆工具库例如structuredClone或专门的工具函数不过我个人建议优先通过拆分状态来避免过深嵌套——这也是下一个大节要讲的话题。注意直接在组件的render阶段修改状态是绝对禁止的行为。如果你在渲染函数中调用setStateReact 会进入无限渲染循环——每次渲染之后状态又变了又要重新渲染如此反复直到抛错。所有状态更新都应该放在事件处理函数、useEffect回调、或异步回调等副作用上下文中。5. 状态设计与拆分很多更新问题其实是设计问题5.1 把大对象拆成多个状态在我评审过的代码里有相当一部分State 不更新的 bug根因不是写入方式不对而是状态结构设计得太散或太深。比如下面这种写法很常见const [formData, setFormData] useState({ name: , email: , address: { city: , street: , zip: , }, preferences: { theme: light, language: zh-CN, notifications: { email: true, push: false, }, }, });几十个字段全部塞在一个大对象里每次修改任何一个字段都要小心翼翼地展开多层对象再重新组合写错一层就出 bug。这种方案不是不能做但对代码的可维护性和正确性很不友好。一个更稳妥的思路是按领域拆分成独立的 stateconst [name, setName] useState(); const [email, setEmail] useState(); const [address, setAddress] useState({ city: , street: , zip: }); const [theme, setTheme] useState(light); const [language, setLanguage] useState(zh-CN);在拆分之后每次更新只需维护一个小型的独立状态几乎不可能误伤其他字段代码的可读性和可维护性也显著提升。5.2 用 useReducer 管理复杂状态转移当状态的更新逻辑比较复杂时比如涉及多种操作类型、状态之间有联动或者更新步骤经常复用时useReducer往往比多个useState更合适。const [state, dispatch] useReducer(reducer, initialState); function reducer(state, action) { switch (action.type) { case INCREMENT: return { ...state, count: state.count 1 }; case ADD_TODO: return { ...state, todos: [...state.todos, action.payload] }; case TOGGLE_TODO: return { ...state, todos: state.todos.map(todo todo.id action.payload.id ? { ...todo, completed: !todo.completed } : todo ), }; default: return state; } }useReducer的价值在于所有的状态更新逻辑集中在一个reducer函数里行为可以被完整测试错误也更容易被追踪。尤其是当多个事件处理函数操作同一份数据时用dispatch语义化地描述你想做什么远比逐个写setState更清晰。5.3 一个状态位能解决的不要拆成两个布尔值有些时候问题恰好相反状态拆得太碎导致更新时序混乱。例如用两个布尔值描述加载状态const [isLoading, setIsLoading] useState(false); const [isError, setIsError] useState(false);如果再加上成功空闲重试中等状态你会陷入哪些布尔值应该同时为 true/false的组合地狱。更好的做法是用一个枚举值const [status, setStatus] useState(idle); // idle | loading | success | error然后用status loading判断加载中status error判断出错。这个小小的调整能避免大量互斥布尔值带来的心智负担和更新顺序 bug。6. 异步更新与并发模式状态更新可能被打断6.1 React 18 并发更新下的优先级React 18 正式引入了并发特性。简单说React 现在可以把更新任务拆成可中断的单元高优先级的更新比如用户输入可以先执行低优先级的更新比如从接口拉取大量数据后的渲染可以被跳过或延迟。对普通开发者来说直接感知到的是某些状态下setState之后界面更新可能看起来更慢了。比如你有一个非常耗时的列表渲染同时用户在输入框中打字React 会让列表渲染让路给输入更新保证交互流畅。这种机制本身是好的但它意味着你不能再假设调用了setState下次代码执行界面上一定已经更新了。永远不要在setState之后做依赖 DOM 状态的同步操作这个原则在并发模式下更加重要。6.2 不要在渲染期间读取状态来推算未来常见的一个错误模式是在render里基于当前状态做计算然后又用计算结果去更新状态function Counter() { const [count, setCount] useState(0); // 错误渲染期间修改状态 if (count 5) { setCount(count 1); } return div{count}/div; }你写着如果 count 小于 5 就加 1想着这样 count 最终会变成 5。但 React 的约定是渲染必须是纯的——输出只依赖 props 和 state不能有副作用。这个组件会在 render 时触发setStateReact 发现状态更新后立刻重新渲染新渲染又触发新的setState最终必然导致Too many re-renders错误。正确的做法是把这种自动更新逻辑放到useEffect里或者通过事件处理函数触发更新useEffect(() { if (count 5) { setCount(count 1); } }, [count]);这个useEffect会在count变化后执行如果count小于 5 就继续增加直到count达到 5。注意我给依赖数组传了[count]保证每次count变化后都会重新判断。6.3 StrictMode 下的两次执行效应如果你用的是官方脚手架创建的项目开发环境下默认开启了StrictMode。StrictMode 会故意让组件的某些生命周期函数和useEffect执行两次目的是帮你发现副作用。很多人第一次遇到为什么 useEffect 跑了两遍或者为什么状态更新看起来不正常时都会怀疑自己代码写错了其实这是 React 开发环境的特性。关键点在于你的状态更新代码必须是幂等的即重复执行两次结果也一样。如果发现 StrictMode 下出现问题先不要急着去掉 StrictMode而是检查自己的更新逻辑里有没有副作用外露比如在渲染期间做随机数生成、直接修改外部变量等。7. 事件驱动的更新 vs 渲染驱动的更新到底该选谁7.1 事件处理中的更新React 的状态更新主要来自两类场景一类是用户交互点击、输入、拖拽等这时状态更新在事件处理函数里执行另一类是外部数据到达接口响应、订阅推送等这时状态更新在异步回调里执行。交互驱动的更新核心要点在本文开头已经整理过批量更新、函数式更新、不可变更新。这是 React 状态更新的主战场也是问题出现最多的地方。7.2 组件挂载/更新后的副作用同步useEffect当状态变化后需要触发某些副作用——比如重新请求数据、聚焦输入框、更新标题——你应该把逻辑放在useEffect中而不是在setState之后直接写。典型例子const [userId, setUserId] useState(null); const [userInfo, setUserInfo] useState(null); useEffect(() { if (!userId) return; fetch(/api/users/${userId}) .then(res res.json()) .then(data setUserInfo(data)); }, [userId]);这样写的好处是无论userId是通过点击按钮、键盘输入还是任何其他方式改变的只要它变了useEffect就会自动执行不需要每个交互点都重复写请求逻辑。7.3 两处更新的常见的重复执行问题一个需要警惕的问题是如果事件处理和useEffect里都写了状态更新可能造成重复逻辑。比如按钮点击时你先setUserId(id)useEffect里再根据userId请求数据这样没问题。如果你在点击时既setUserId(id)又手动请求了一次数据就会导致重复请求。我见过不少类似的 bug现象是接口被调用了两次。排查时一眼能看到原因事件处理函数里手动调了一次请求useEffect又调了一次。这种问题用一句话概括就是数据请求应该由状态变化驱动而不是由用户操作驱动。同样的逻辑只保留一处即可。8. State 更新的性能思考别让正确变成低效8.1 合并相关状态减少渲染次数React 的性能优化有一条很关键的原则渲染次数越少越好。但是如果你把多个毫不相关的数据塞进同一个state对象只要你更新其中一个字段整个对象都会更新所有依赖这个对象的组件都会重新渲染。举个例子const [user, setUser] useState({ name: 张三, avatar: https://example.com/avatar.jpg, }); const [unreadCount, setUnreadCount] useState(0);如果每收到一条新消息就调用setUser({ ...user })那整个user对象都会变化哪怕你只是想把unreadCount加 1。但如果unreadCount是一个独立的state受影响的面就小得多。这也是我上面强调的按领域拆状态而不是为了少写几个 useState硬塞一个大对象。8.2 useMemo 和 useCallback避免无意义的子组件重渲染当父组件更新状态时所有子组件都会默认重新渲染。如果某个子组件渲染开销很大你可以用memo让它只在 props 变化时重渲染同时用useCallback和useMemo让传给子组件的函数和值保持稳定引用。const handleDelete useCallback((id) { setList(prev prev.filter(item item.id ! id)); }, []); // 子组件用 memo 包裹 const ListItem memo(function ListItem({ item, onDelete }) { // ... });注意这里有个经典配合setList(prev prev.filter(...))是函数式更新它不依赖外部变量所以handleDelete的依赖数组可以保持为空。如果你写成setList(list.filter(...))就必须把list放进依赖数组而list一变handleDelete引用就变memo优化就失效了。8.3 大量更新时使用 useTransitionReact 18 还提供了useTransition用于标记非紧急更新。比如搜索输入时输入框本身要立即响应而搜索结果列表的计算/渲染可以让位于输入体验const [isPending, startTransition] useTransition(); const [keyword, setKeyword] useState(); const [searchResult, setSearchResult] useState([]); const handleChange (e) { setKeyword(e.target.value); startTransition(() { setSearchResult(heavyFilterList(e.target.value)); }); };这里setKeyword是紧急更新输入框立刻响应setSearchResult被放进startTransitionReact 会把它当作低优先级任务优先保证用户输入流畅。这个 API 适合处理数据量大、计算耗时的场景普通小列表没必要用。9. 一整套避坑清单把这些写进团队 Code Review 规范里9.1 常见问题速查表把全文提到的坑集中起来方便对照坑症状正确做法渲染期间直接修改状态React 报 Too many re-renders把逻辑移到事件处理或useEffectsetState(prev prev 1)与setState(count 1)混用连续更新结果不对依赖旧值时统一用函数式更新直接修改对象/数组后传入 setState界面不更新或更新错乱先创建新引用再传入在setState之后同步操作 DOM操作的是旧 DOM把副作用放到useEffect大对象状态嵌套多层更新代码冗长、易错按领域拆分成多个独立状态互斥布尔状态过多组合状态混乱用单个枚举状态替代事件处理和useEffect都触发同一逻辑函数重复执行只保留一处驱动逻辑同步代码里依赖自动批处理的结果中间状态读取为旧值用函数式更新或重新设计数据流9.2 实操建议如何给同事讲清 state 更新的三个核心原则如果要在团队里培训新人我会把内容压缩成三句话setState 是异步的别指望在调用后立刻拿到新值。新值依赖旧值时传函数给 setState。对象和数组必须不可变更新创建新引用。这三句话能覆盖日常开发中大部分状态更新的问题。熟练掌握之后再逐步理解批处理、并发优先级、useReducer这些进阶概念。9.3 一个实用的调试技巧善用 Why Did You Render排查状态更新导致的性能问题和重复渲染时在应用开发阶段可以使用welldone-software/why-did-you-render这类辅助工具它会在组件发生非必要渲染时在控制台打出提示。这类工具的定位是开发期辅助生产环境需要去除。我一般在大型项目、层级深的组件树里才会开启小型项目用不到反而干扰注意力。10. 最后分享两个我踩过的真实教训第一个教训发生在做一个支付流程页面时。需求是用户点击支付后按钮进入 loading 态同时发起请求。我当时写了类似这样的代码const handlePay () { setLoading(true); // 由于自动批处理此时 React 还没有渲染 loading 态 const result await requestPayment(); setLoading(false); };结果测试发现在某些低端手机上用户点击支付后界面毫无反应过了一两秒才直接跳到支付完成状态。原因是点击事件里的setLoading(true)在并发模式下被中断或延迟了await之后的setLoading(false)又把状态改了回来中间根本没有机会渲染 loading 态。修复方案是让 loading 状态与请求状态分离并确保按钮本身自带防止重复点击的逻辑不能依赖 loading 态的同步渲染来阻止二次点击。第二个教训更基础是刚写 React 时直接把数组push后传入setState界面怎么都不更新控制台里看数组却已经有数据了。后来才彻底理解了引用比较的机制。这两个问题看似普通但几乎每个刚上手 Hooks 的人都会遇到所以我很建议你把本文的不可变更新原则打印出来贴在工位上写代码前扫一眼。State 更新这件事表面上是 React 的 API 使用问题本质上是对数据流和渲染机制的理解问题。把这两层弄清楚你写的 React 代码会稳定很多排查问题也会快很多。
阅读完成 · 觉得有帮助?