组里新人写了个简单的"+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 步
- 装 React DevTools,看组件实际的 props 和 state
- 在 setter 调用前后加
console.log,看是不是按预期触发 - 用 React DevTools 的 Profiler 录制一段交互,看哪些组件因 state 变化重渲染
- 在 useEffect 里加
console.log(deps)看依赖捕获的值是不是预期的 - 用
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 真理
- 新值依赖旧值,永远用函数式 setter:
setX(prev => prev + 1) - state 永远不可变:用扩展符 / immer 创建新引用
- setState 不立刻生效:要读新值用 useEffect
- 异步回调 race condition 用 AbortController + 清理函数
- 不需要触发渲染的值用 useRef,别滥用 useState
把这 5 条钉墙上,我们组接手 React 项目的新人,头一个月的 setState 类 bug 直接降低 80%。一行规则解决一类问题,投资回报极高。
—— 别看了 · 2026