React useState 的函数式更新常被面试题围绕,但核心并不只是记住“setState 是异步的”。更准确的切入点是:函数组件每次渲染都会创建独立闭包,事件、setTimeout、Promise、useEffect 回调捕获的是创建时的 state;React 又会把更新放入队列并可能批处理。直接写 setCount(count + 1),计算在旧闭包里完成;函数式写法 setCount(previousCount => previousCount + 1) 把计算延迟到 React 处理队列时,按顺序拿到前一次更新后的最新状态。本文按错误现象、异步回调、更新调度、边界误区和使用场景重组这些面试点。
一、连续三次 setCount(count + 1) 为什么只加一
先看错误写法与修复写法:
- const [count, setCount] = useState(0);
- const handleWrongTripleIncrement = () => {
- setCount(count + 1);
- setCount(count + 1);
- setCount(count + 1);
- };
- const handleCorrectTripleIncrement = () => {
- setCount(previousCount => previousCount + 1);
- setCount(previousCount => previousCount + 1);
- setCount(previousCount => previousCount + 1);
- };
复制代码
假设本次渲染时 count 为 0,三次 setCount(count + 1) 都读取同一个旧闭包值,都计算出 0 + 1 = 1。React 批处理时后一次更新覆盖前一次更新,最终新状态是 1,而不是 3。函数式更新会把 updater 放入更新队列,React 按顺序执行:previousCount 为 0 时返回 1,下一次 previousCount 为 1 时返回 2,再下一次 previousCount 为 2 时返回 3。最终结果才是 3。
如果只是连续加三,也可以合并成一次更新:setCount(previousCount => previousCount + 3)。复杂状态逻辑则推荐 useReducer,把多个相互依赖的更新动作集中管理。
二、异步回调和 useEffect 追加数据为什么会丢更新
setTimeout、Promise、useEffect 异步回调也可能捕获旧 state。下面的错误写法在快速点击两次时,两个回调都读取同一个旧 count,最终只加 1;正确写法让 React 执行 updater 时传入最新状态,两次更新可按序得到 1 和 2。
- const handleWrongAsyncIncrement = () => {
- window.setTimeout(() => {
- setCount(count + 1);
- }, 1000);
- };
- const handleCorrectAsyncIncrement = () => {
- window.setTimeout(() => {
- setCount(previousCount => previousCount + 1);
- }, 1000);
- };
复制代码
useEffect 中追加数组数据同理。错误写法 setData([...data, newItem]) 里的 data 是 useEffect 执行时闭包捕获的旧数组;异步请求返回后直接基于旧 data 追加,可能覆盖期间其他更新。正确写法是 setData(previousData => [...previousData, newItem]),让追加逻辑基于最新 data 执行。
但函数式更新只解决“基于旧状态计算新状态”的闭包问题,不解决请求竞态。快速切换 id 时,旧请求可能比新请求更晚返回,仍可能覆盖新响应,需要 AbortController、请求序号或忽略旧响应来处理。
三、useState 的更新一定是异步的吗
更准确的说法是:调用 setCount 后,当前渲染中的 state 不会立即改变,React 会把更新加入队列并调度重新渲染。它不是 Promise 异步,而是批处理与调度机制。如下代码中 console.log(count) 读到的仍然是旧值:
- setCount(count + 1);
- console.log(count);
复制代码
在 React 18 的 createRoot 下,setTimeout、Promise、原生事件中的更新也会自动批处理;React 17 及以前,只有 React 事件处理函数中的更新默认批处理。多次 setState 可能被合并,只触发一次渲染。需要强制同步刷新时可以使用 flushSync,但很少用,容易影响性能。
四、什么时候用函数式更新,什么时候不用
新状态依赖旧状态时,应使用函数式更新:计数器 setCount(previousCount => previousCount + 1),切换布尔值 setVisible(previousVisible => !previousVisible),数组追加 setList(previousList => [...previousList, newItem]),对象合并 setForm(previousForm => ({ ...previousForm, name: '新值' })),以及在 setTimeout、Promise、useEffect 异步回调中更新状态、短时间内连续多次更新同一个状态。
新状态是独立确定值时,直接传值更清晰:setName('张三')、setLoading(true)、setSelectedId(id)。updater 必须是纯函数,不要在里面发请求、修改外部变量或写 DOM。React StrictMode 开发环境下,updater 可能被调用两次,用来检查是否为纯函数。
常见误区也要分清:setState 不是 Promise 异步,而是入队并延迟处理;不是只有异步回调才需要函数式更新,连续同步更新、批处理、依赖旧状态时同样需要;函数式更新也不能解决所有异步问题,它只解决旧状态闭包,不解决请求竞态、组件卸载后更新等问题。服务端数据缓存、竞态处理、重试等场景可考虑 React Query、SWR;复杂本地状态可考虑 useReducer。
五、useEffect 追加数据并取消旧请求的关键写法
依赖数组只写 id 可以避免 data 变化导致重复请求,但异步回调中的 data 仍是旧闭包值,因此追加时必须使用函数式更新。同时用 AbortController 取消旧请求,避免旧请求晚返回造成竞态;finally 中检查 aborted,避免旧请求影响新请求的 loading 状态。
- useEffect(() => {
- const abortController = new AbortController();
- async function loadData() {
- try {
- setLoading(true);
- const newItem = await fetchDataById(id, abortController.signal);
- setData(previousData => [...previousData, newItem]);
- } catch (error) {
- if (error.name !== 'AbortError') {
- console.error('加载数据失败:', error);
- }
- } finally {
- if (!abortController.signal.aborted) {
- setLoading(false);
- }
- }
- }
- loadData();
- return () => {
- abortController.abort();
- };
- }, [id]);
复制代码
总结:只要新状态依赖旧状态,尤其是连续更新或异步回调中,就优先使用函数式更新;新状态是独立确定值时直接传值即可。函数式更新保证 updater 按顺序拿到最新状态,但请求竞态仍需 AbortController 或请求序号,复杂状态和服务器状态则应选择 useReducer、React Query、SWR 等更合适的方案。 |