学完这篇,你能把 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`