学完这篇,你能把 React 18 的 useTransition 和 startTransition 真正落到搜索框、路由切换这类会卡的场景里,让"输入跟手、结果慢半拍"成为默认体验。
第一步:先确认你的项目真的跑在并发模式上
React 18 的并发特性只在 createRoot 下生效。如果你还在用老写法:
// ❌ React 17 写法,并发特性不生效
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
换成:
// ✅ React 18 写法
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);
注意:package.json 里 react 和 react-dom 都必须 ≥ 18,且两者版本一致。只在 react 上升级、react-dom 留在 17,会在运行时直接报错。
第二步:理解它到底解决什么问题
React 的更新默认都是"紧急"的:一次 setState 触发整棵树重渲染,全部算完才交给浏览器绘制。于是就有了这个经典卡顿——你在输入框里打字,每次按键都触发一个几千条数据的列表过滤,主线程被算满,输入框回显延迟,手感发涩。
useTransition 的作用就是给更新分级:
- 紧急更新:输入框的
value,必须立刻回显
- 非紧急更新:过滤后的列表,可以被打断、可以让路
第三步:最小可用例子
import { useState, useTransition } from 'react';
export default function Search() {
const [query, setQuery] = useState('');
const [list, setList] = useState(ALL_ITEMS);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // 紧急:立刻回显
startTransition(() => { // 非紧急:可以被打断
setList(ALL_ITEMS.filter(it => it.name.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="搜索…" />
<ul style={{ opacity: isPending ? 0.5 : 1 }}>
{list.map(it => <li key={it.id}>{it.name}</li>)}
</ul>
</>
);
}
注意返回值顺序是 [isPending, startTransition],别写反,写反了会得到一个函数当布尔值用。
isPending 表示"这个 transition 还没渲染完"。用它做淡化提示而不是整个屏幕转圈,能避免闪烁。
第四步:组件外怎么用 startTransition
useTransition 是 Hook,只能在组件里调。在路由跳转、状态管理库回调这些地方,用顶层导出的函数版本:
import { startTransition } from 'react';
function navigate(nextPath) {
startTransition(() => {
setRoute(nextPath); // 页面切换不再阻塞当前交互
});
}
两者调用的是同一套机制,区别只在于函数版本拿不到 isPending。
第五步:和 useDeferredValue 怎么选
const deferredQuery = useDeferredValue(query);
const list = useMemo(() => filter(ALL_ITEMS, deferredQuery), [deferredQuery]);
判断标准很简单:**你能拿到那个 setState 就用手 Transition,拿不到(比如值是从 props 传下来的)就用 useDeferredValue。** 两者经常可以互换,但 useDeferredValue 不能标记"这一批更新",只能延迟单个值。
注意:startTransition 的回调必须是同步的。在 React 18 里写 startTransition(async () => { await ... }),await 之后的 setState 已经脱离 transition 上下文,不会按预期降级(React 19 才正式支持 async transition)。
注意:受控输入框的 value 更新绝对不能放进 startTransition。那会让输入框本身变卡,等于把问题原样搬了个位置。
注意:Transition 只降低"更新优先级",不减少计算量。列表项如果很重,请给子组件套 React.memo,否则父组件每次重渲染仍会带着整棵子树重算,效果大打折扣。
第六步:验证效果
装 React DevTools,打开 Profiler 面板录制一次输入过程,能看到标记为 Transition 的渲染被拆成多次、被后续按键打断。同时观察 isPending 在两帧之间快速翻转——如果它一直 false,八成是没走 createRoot,或者这个更新根本没被包进 transition。
配合 Suspense 时还有个附带好处:transition 中的更新即使挂起,也不会弹出 fallback,而是保留旧内容直到新内容就绪,视觉上更连续。
小结
- 并发特性只在
createRoot 下生效,先确认入口写法
const [isPending, startTransition] = useTransition(),顺序别写反
- 紧急更新(输入回显)单独
setState,非紧急更新包进 startTransition
- 组件外用
import { startTransition } from 'react'
- 拿得到 setter 用 transition,拿不到用
useDeferredValue
- 回调必须同步;受控输入值不进 transition;重列表子项要
memo