用 React 18 并发模式折腾了半年,踩过的状态同步坑都在这了

从 React 16 的 fiber 架构到 18 的并发模式,状态更新的时序模型彻底变了。以前你能依赖的“渲染完一定是最新状态”这个假设,在 startTransition、useDeferredValue 和自动批处理面前不再成立。半年下来,我踩过的坑基本都围绕一个核心矛盾:你的心智模型还停留在同步更新,但 React 已经在并发调度里把你的状态分成了多个“版本”。

坑一:onChange 里拿到的值和渲染的值对不上

这个问题最早出现在一个搜索输入框组件里。用户在输入框里打字,我需要在 onChange 里读取 input 的值去做一些副作用,比如调接口或者更新另一个状态。代码长这样:

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

const handleChange = (e) => {
  const value = e.target.value;
  setQuery(value);
  // 这里直接拿 query 去请求?不对
  fetchResults(query); // query 还是旧值
};

这个坑其实跟并发模式关系不大,是闭包陷阱的老问题。但在 React 18 里,事情更微妙了。即使你用 useEffect 去监听 query 变化,如果你的搜索请求被包在 startTransition 里,query 的更新可能被标记为非紧急,而用户又快速输入了下一个字符,前一次请求就可能在还没发出时就被新一轮更新“取消”掉。

真正让我头疼的是这种场景:我用了 useDeferredValue 来优化输入流畅度,结果发现在 onChange 里拿到的 e.target.value 是用户实际输入的值,但页面渲染出来的 deferredQuery 还停留在旧版本。这时候如果你基于 e.target.value 更新了另一个状态,而那个状态又没做并发处理,就会出现 A 组件显示的是新值,B 组件显示的是旧值,而且这两个组件读的是同一个 state。根因是 useDeferredValue 返回的是前一次渲染的“快照”,你的 onChange 逻辑里直接操作的是原生事件对象,绕过了 React 的调度。

解法是:把需要即时同步的逻辑放在 flushSync 里,或者干脆放弃在事件处理器里读 state,直接从事件对象拿值。 如果一定要基于 state 做计算,用 useRef 存一份镜像,在每次渲染时同步更新 ref.current,这样你在任何回调里读到的都是最新的提交值。

const queryRef = useRef(query);
queryRef.current = query;

const handleChange = (e) => {
  const value = e.target.value;
  setQuery(value);
  // 用 ref 拿最新值
  doSomething(queryRef.current);
};

坑二:useTransition 里的多个 setState 不是事务性的

React 18 的自动批处理把同一个事件处理器里的多次 setState 合并成一次渲染,这点在并发模式下依然成立。但一旦你用了 startTransition,情况就变了。

我在一个表单页面里遇到了这样的问题:用户提交表单时,我先 setLoading(true),然后执行一段耗时计算,最后 setLoading(false)。我把整段逻辑包在 startTransition 里,目的是让 loading 状态的更新不要阻塞用户的其他操作。结果发现,setLoading(true) 和 setLoading(false) 之间的中间状态根本不会渲染出来——因为 startTransition 内部的更新会被 React 打上“可中断”标记,如果在这段过渡期内有更高优先级的更新进来(比如用户又点了一次按钮),整个过渡会被丢弃,包括那个 setLoading(true)。

更隐蔽的问题是,startTransition 里的多个 setState 并不是原子执行的。看这段代码:

startTransition(() => {
  setPage(2);
  setData(newData);
});

你以为 page 和 data 会同时更新,但实际上 React 可能会在 page 更新后、data 更新前插入一个高优先级的渲染(比如用户滚动触发了某个事件)。如果你的组件在渲染时依赖 page 和 data 的一致性,就会读到撕裂状态。这个问题在 React 18 的官方文档里被叫做“tearing”,虽然 React 团队声称并发渲染不会导致视觉上的撕裂,但那仅限于用 useState 和 useReducer 的场景。如果你用了外部 store(比如 zustand、redux),而且没有用 react-redux 8 或 zustand 4 提供的 useSyncExternalStore 封装,撕裂就会实打实地出现。

解法很明确:要么把所有相关联的状态合并成一个 useReducer,要么用 useSyncExternalStore 订阅外部 store。 我选择了前者,因为表单状态本来就是一个整体,用 reducer 管理更自然。

const [state, dispatch] = useReducer(formReducer, initialState);

startTransition(() => {
  dispatch({ type: 'SUBMIT', page: 2, data: newData });
});

坑三:useEffect 里的 cleanup 在并发模式下被调用的时机变了

React 18 的严格模式会在开发环境双重调用 useEffect,这个很多人都知道。但并发模式下,cleanup 函数的调用时机还有一个更隐蔽的变化:当一次更新被中断并重新开始时,上一次渲染的 effect cleanup 会在新的 effect 执行前被调用,即使这两次渲染对应的是同一个“屏幕状态”。

这个坑出现在一个实时协作编辑器里。我在 useEffect 里建立 WebSocket 连接,在 cleanup 里断开。用户的每次输入都会触发一次 setState,而这些 setState 被包在 startTransition 里。结果发现,当用户快速输入时,WebSocket 连接会被频繁地断开又重连——因为 React 在中断前一次渲染时调用了 cleanup,然后在新渲染时又调用了 effect。

问题的本质是:useEffect 的 cleanup 在并发模式下不再代表“组件卸载”,而是代表“这次渲染的结果被丢弃”。 如果你的 cleanup 里有副作用(比如断开连接、清除定时器),而这些副作用的触发频率远高于你的预期,就会导致资源抖动。

我最初的解法是把 WebSocket 连接移到 useRef 里,手动管理生命周期。但后来发现 React 18 提供了一个更优雅的 hook:useSyncExternalStore。虽然它主要用于订阅外部 store,但也可以用来订阅任何可变数据源。关键在于,useSyncExternalStore 的回调在并发模式下不会被重复调用——它保证每次提交只执行一次。

const socketRef = useRef(null);

useEffect(() => {
  socketRef.current = new WebSocket(url);
  return () => {
    // 只有在组件真正卸载时才断开
    socketRef.current?.close();
  };
}, [url]);

把 url 作为依赖,只要 url 不变,cleanup 就不会因为并发中断而被误触发。如果 url 变了,那确实是需要重连的,cleanup 被调用也合理。

坑四:useDeferredValue 的“延迟”比你想象的更激进

useDeferredValue 的官方描述是“返回一个延迟更新的值”,很多人理解为“等主线程空闲了再更新”。但实际上,它的行为更接近于“在并发渲染被中断时,优先保证旧值的渲染不被阻塞,新值的渲染可以等”。

我在一个数据可视化页面里用 useDeferredValue 处理筛选条件变化。用户拖拽一个滑块,我根据滑块的值过滤数据集,然后重新渲染图表。过滤和渲染加起来大概要 30ms,我用了 useDeferredValue 包裹过滤后的数据,期望滑块拖拽时保持流畅。

结果发现,当用户快速拖拽滑块时,图表根本不动——因为 useDeferredValue 一直在等待一个“空闲”时机,但用户的手一直在拖,新的高优先级更新不断进来,延迟值始终没有机会被提交。用户停下来的瞬间,图表才跳到最终状态。这个体验比不用 useDeferredValue 还差,因为不用的时候至少图表在跟着动,只是有点卡。

根因是 useDeferredValue 的超时机制在 React 18.0 到 18.2 之间有过调整。在 18.0 里,延迟值没有强制超时,完全依赖调度器判断。如果你的高优先级更新持续不断,延迟值可能永远不更新。React 18.2 加了启发式超时,但依然不保证延迟值会在多快的时间内更新。

解法是不要用 useDeferredValue 处理用户直接操作的数据。 它适合的场景是:某个状态的更新源不是你直接控制的(比如来自网络请求、定时器),而且这个状态的旧值仍然有意义(比如列表数据还没加载完时显示旧的列表)。对于滑块、输入框这类连续操作,用节流或者干脆接受短暂的不流畅更合理。

坑五:Suspense 和并发状态更新的交互让人抓狂

Suspense 在 React 18 里支持了并发模式下的“流式渲染”,但如果你在 Suspense 包裹的组件内部使用了 startTransition,行为会变得很难预测。

具体场景:一个页面有侧边栏和主内容区,主内容区用 Suspense 包裹,数据通过 React.lazy 或者支持 Suspense 的数据获取库(如 react-query)加载。用户点击侧边栏的导航项时,我触发 startTransition 来更新路由状态和请求新的数据。

问题出在:当新数据还在加载时,React 会显示 fallback(比如骨架屏)。但如果用户在 fallback 显示期间又点击了另一个导航项,前一次的过渡会被中断,新的一次开始。这本身是期望的行为,但 React 18.0 到 18.2 之间有一个 bug:被中断的 Suspense 过渡可能导致 fallback 的清理不彻底,残留的 DOM 节点没有被正确移除。 我在一个使用 React.lazy 的动态导入场景里复现了这个问题,页面在快速切换时出现了两个骨架屏叠加的情况。

这个 bug 在 React 18.3 里被修复了,但如果你用的是 18.2 或更早版本,需要手动处理。我的 workaround 是用 key 属性强制 Remount:

<Suspense fallback={<Skeleton />} key={routeId}>
  <MainContent routeId={routeId} />
</Suspense>

每次 routeId 变化时,Suspense 的子树会被完全卸载再重新挂载,避免了残留问题。代价是失去了并发模式下的“保留旧内容直到新内容准备好”的特性,但在我的场景里这个特性本身就是在快速切换时出问题的根源。

常见问题

startTransition 和 useTransition 到底有啥区别,什么时候用哪个?

startTransition 是直接从 react 导出的函数,可以在任何地方调用,比如事件处理器、useEffect、甚至 setTimeout 里。useTransition 是一个 hook,返回 [isPending, startTransition],只能在组件顶层调用。如果你只需要在组件内部标记低优先级更新,用 useTransition 更方便,因为 isPending 能让你在 UI 上显示过渡状态。如果你需要在组件外部(比如在 store 的 action 里)标记过渡,用 startTransition。

React 18 的自动批处理在什么情况下会失效?

自动批处理只在 React 事件处理器(比如 onClick、onChange)和生命周期方法里生效。在 setTimeout、Promise.then、原生事件处理器、以及 async/await 之后的代码里,React 18 之前不会批处理,但 18 通过 createRoot 启用了自动批处理后,这些异步场景也会被批处理。唯一例外是 flushSync 包裹的代码——它会强制同步渲染,绕过批处理。

useDeferredValue 和防抖/节流有什么区别,能互相替代吗?

不能。防抖和节流是时间维度的控制,它们决定的是“多久之后才更新状态”,完全在 React 的调度之外。useDeferredValue 是 React 调度器内部的机制,它决定的是“在并发渲染中,新值什么时候被提交到屏幕上”,不延迟状态的更新本身,只延迟渲染结果的展示。如果你的目标是减少请求频率,用防抖;如果目标是保持 UI 响应,用 useDeferredValue。

用了 useSyncExternalStore 就一定不会出现撕裂吗?

理论上是的,但前提是你的外部 store 确实是通过 useSyncExternalStore 订阅的,而且 store 的更新逻辑是同步的。如果你的 store 内部有异步逻辑(比如在 subscribe 回调里又调了 setState),那撕裂依然可能发生。useSyncExternalStore 保证的是:在 React 的同一个渲染批次内,多次读取 store 会返回一致的值。但如果 store 的值在渲染过程中被异步修改了,React 会检测到冲突并重新渲染。