React useState 连点 3 次只 +1 的真相:批量更新 + 函数式 setter 完全指南

新人写的 +1 按钮连点 3 下只涨 1,问我是不是 React bug。本文讲透 useState 批量更新陷阱、async race condition、React 18 Automatic Batching、useState vs useRef 边界、useReducer 切换时机,以及 5 条钉墙上的真理。

组里新人写了个简单的"+1"按钮,连点 3 下,期望值从 0 涨到 3,实际只涨到 1。然后他来问我:"是不是 React 的 bug?"。这不是 bug,是几乎所有 React 开发者第一年都会踩的坑:useState 的批量更新 + 闭包陷阱。本文讲清楚为什么会这样、什么时候要用函数式 setter、React 18 之后又有哪些新变化。

最小复现

import { useState } from 'react';

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

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // 期望:count 增加 3
    // 实际:count 只增加 1
  }

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

原因是这 3 次 setCount 调用里,count 都是同一个值(本次渲染时的 count,假设是 0)。所以三次都是 setCount(0 + 1),即 setCount(1)

React 把这三次更新合并成一次,最终 count 变成 1。

修法 1:函数式 setter

function handleClick() {
  setCount(c => c + 1);    // c 是<最新>的 state,而不是渲染时的 count
  setCount(c => c + 1);
  setCount(c => c + 1);
  // count 增加 3 ✓
}

函数式 setter 的参数 c 是 React 内部维护的最新状态,而不是闭包里的旧值。三次 setter 链式更新:0 → 1 → 2 → 3。

规则:如果新 state 依赖旧 state,永远用函数式 setter

真实场景:异步回调里的过期 state

这种坑更隐蔽,通常出现在异步代码里:

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  async function handleSearch() {
    const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
    setResults(data);     // 用户可能已经改了 query,但这里用的还是旧 query
  }

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}

用户输 "abc" 点搜索,fetch 还没回来又输 "abcd" 又点搜索,然后两个请求按 abc 先回来、abcd 后回来 —— 结果 results 是 abc 的(被 abcd 覆盖了之后,abc 的回包又把结果改回 abc 的)。这是经典的 race condition。

修法:用 AbortController 取消旧请求 + useEffect 替代直接调:

import { useEffect, useState } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }
    const ctrl = new AbortController();
    setLoading(true);

    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })
      .then(r => r.json())
      .then(data => {
        setResults(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
          setLoading(false);
        }
      });

    return () => ctrl.abort();    // 关键:query 变化时取消上一次请求
  }, [query]);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {loading && <span>loading...</span>}
      <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}

这套 pattern 几乎是「数据获取 with 取消」的标准写法。每次 query 变化都触发 useEffect 重跑,旧的 fetch 被 abort,新的 fetch 启动。

React 18 的自动批量更新

React 18 之前,只有React 事件处理函数里的多次 setState 会被批量合并。setTimeout / Promise.then / fetch 回调里的多次 setState 是同步触发的,每次都会立即重新渲染。

// React 17 行为
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // → 一次 batched 更新,一次重新渲染

  setTimeout(() => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // → React 17:两次单独的更新,两次重新渲染
    // → React 18:依然 batched,一次重新渲染
  }, 0);
}

React 18 起,所有地方的 setState 默认都是 batched,叫 Automatic Batching。这是好事,减少了多余渲染。

但有时候你需要立刻更新而不是 batched,比如要在下一行代码里读 DOM:

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  });
  // 这里 React 已经重新渲染完了,可以读到新 DOM
  console.log(divRef.current.scrollTop);
}

flushSync 是性能 hazard,99% 场景不需要。

useState 的 setter 不保证立刻执行

新人常以为 setState 是同步的,经常踩这个坑:

function handleSubmit() {
  setLoading(true);
  // 期望:接下来的代码看到 loading=true
  console.log(loading);    // false!loading 还是上次的值

  await submitForm();      // 一段耗时的提交
  setLoading(false);
  // 同样,这里 console.log(loading) 还是 true(刚才那个值)
}

setState 调用后,组件还要走完当前函数才会重渲染。要读最新值,只能在下一次渲染里。

如果业务逻辑必须知道"setState 是否已经生效",通常应该用 useEffect:

useEffect(() => {
  if (loading) {
    console.log('loading 真的开始了');
  }
}, [loading]);

State 是 immutable 的

// 错:直接修改 state 对象
function addItem(item) {
  items.push(item);        // 修改了旧 state 对象
  setItems(items);         // React 看不出 items 引用变了,不会重渲染
}

// 对:创建新数组
function addItem(item) {
  setItems(prev => [...prev, item]);
}

// 嵌套对象同样要每层都新建
const [user, setUser] = useState({ name: 'A', profile: { age: 18 } });

// 错
user.profile.age = 19;
setUser(user);

// 对
setUser(prev => ({
  ...prev,
  profile: { ...prev.profile, age: 19 }
}));

// 深层嵌套用 immer 更舒服
import { produce } from 'immer';

setUser(produce(draft => {
  draft.profile.age = 19;
  draft.tags.push('newbie');
}));

useReducer:复杂 state 的更好选择

当 state 有 4-5 个相关字段,setter 互相依赖,用 useState 写起来满屏都是 setter,这时候 useReducer 更清爽:

// useState 版(state 之间有依赖,很难维护)
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitted, setSubmitted] = useState(false);

  function handleSubmit() {
    setErrors({});           // 清错
    setSubmitting(true);     // 标记提交中
    setSubmitted(false);
    // ...
  }
  // 等等,如果还有别的 state 转换呢?
}

// useReducer 版:状态机思路
const initialState = {
  name: '', email: '', errors: {},
  status: 'editing'   // editing / submitting / submitted / failed
};

function reducer(state, action) {
  switch (action.type) {
    case 'CHANGE_FIELD':
      return { ...state, [action.field]: action.value };
    case 'SUBMIT':
      return { ...state, errors: {}, status: 'submitting' };
    case 'SUBMIT_OK':
      return { ...state, status: 'submitted' };
    case 'SUBMIT_FAIL':
      return { ...state, errors: action.errors, status: 'failed' };
    default:
      return state;
  }
}

function Form() {
  const [state, dispatch] = useReducer(reducer, initialState);

  async function handleSubmit() {
    dispatch({ type: 'SUBMIT' });
    try {
      await submitForm(state.name, state.email);
      dispatch({ type: 'SUBMIT_OK' });
    } catch (e) {
      dispatch({ type: 'SUBMIT_FAIL', errors: e.errors });
    }
  }
  // 业务逻辑全在 reducer 里集中,测试也好写
}

性能陷阱:useState 频繁更新

有时候 state 变化很频繁(比如 mousemove 跟踪鼠标位置),会触发太多渲染,卡到掉帧:

// 错:每次 mousemove 都 setState,60 fps 一秒 60 次重渲染
function MousePosition() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handler = e => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);

  return <div>{pos.x}, {pos.y}</div>;
}

// 对:requestAnimationFrame 节流
function MousePosition() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    let rafId = null;
    let lastEvent = null;

    const handler = e => {
      lastEvent = e;
      if (rafId === null) {
        rafId = requestAnimationFrame(() => {
          setPos({ x: lastEvent.clientX, y: lastEvent.clientY });
          rafId = null;
        });
      }
    };
    window.addEventListener('mousemove', handler);
    return () => {
      window.removeEventListener('mousemove', handler);
      if (rafId) cancelAnimationFrame(rafId);
    };
  }, []);

  return <div>{pos.x}, {pos.y}</div>;
}

或者用 useRef 存高频变化的值,只有真正要触发渲染时才 setState。

useState vs useRef 的边界

// 需要触发渲染:用 useState
const [count, setCount] = useState(0);

// 不需要触发渲染(比如保存 timer id / 上一次的 prop / DOM 引用):用 useRef
const timerRef = useRef(null);

useEffect(() => {
  timerRef.current = setInterval(() => {
    console.log('tick');
  }, 1000);
  return () => clearInterval(timerRef.current);
}, []);

很多新人把 timer id / 计算缓存放 state 里,导致每次更新都触发渲染。这些值用 useRef 存最合适。

排查 useState bug 的 5 步

  1. 装 React DevTools,看组件实际的 props 和 state
  2. 在 setter 调用前后加 console.log,看是不是按预期触发
  3. 用 React DevTools 的 Profiler 录制一段交互,看哪些组件因 state 变化重渲染
  4. 在 useEffect 里加 console.log(deps) 看依赖捕获的值是不是预期的
  5. why-did-you-render 这个库检测无效渲染
// why-did-you-render 配置
// wdyr.js
import React from 'react';
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
    logOnDifferentValues: true,
  });
}

// 在 index.tsx 顶部 import './wdyr';
// 然后给要追踪的组件加 .whyDidYouRender = true
function ExpensiveList(props) { ... }
ExpensiveList.whyDidYouRender = true;

总结 5 条 useState 真理

  1. 新值依赖旧值,永远用函数式 setter:setX(prev => prev + 1)
  2. state 永远不可变:用扩展符 / immer 创建新引用
  3. setState 不立刻生效:要读新值用 useEffect
  4. 异步回调 race condition 用 AbortController + 清理函数
  5. 不需要触发渲染的值用 useRef,别滥用 useState

把这 5 条钉墙上,我们组接手 React 项目的新人,头一个月的 setState 类 bug 直接降低 80%。一行规则解决一类问题,投资回报极高。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Python 函数默认参数是 [] 让我们订单串单的 P1 事故复盘

2026-5-19 10:31:12

技术教程

HTTPS 握手慢 380ms 排查:OCSP Stapling 救场的全过程

2026-5-19 10:35:24

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索