startTransition是 React 18 引入的核心并发特性。本文从设计动机到内部机制,把它讲清楚。
1. 它要解决什么问题
React 16/17 的所有 setState 都是同等优先级——同步更新、立刻提交。这导致一个困境:
function Search() {
const [input, setInput] = useState("")
const [results, setResults] = useState([])
const onChange = (e) => {
setInput(e.target.value) // 应该立刻响应
setResults(filterHugeList(e.target.value)) // 慢,但不紧急
}
}
输入框打字必须立刻响应(用户的视觉反馈),但筛选 1 万条数据可以延迟。两个 setState 混在一起,整个事件被慢的那个拖垮,输入卡顿。
React 18 引入「优先级」概念:紧急更新(urgent)和过渡更新(transition)。startTransition 就是把回调里的 setState 标记成 transition:
const onChange = (e) => {
setInput(e.target.value) // urgent,立刻提交
startTransition(() => {
setResults(filterHugeList(...)) // transition,可被打断
})
}
2. 跟普通 setState 的本质区别
不是「延迟执行」,是「可被打断的渲染」。
普通更新:
setState → React 同步走完整渲染流程 → DOM 更新
↑ 用户必须等到这里才看到响应
transition 更新:
startTransition → 标记一批 setState 为低优先级
→ React 开始渲染新树(在内存里)
→ 期间如果有 urgent 更新进来 → 暂停 transition、先处理 urgent
→ urgent 处理完 → 继续 transition(可能重新开始)
→ 最终新树准备好 → commit
关键点:
- transition 渲染发生在「幕后」,不阻塞用户操作
- 可以被中断、丢弃、重做
- 旧的 UI 一直在屏幕上,直到新 UI 准备好才原子地切换
3. 两种用法
// 形式 A:带 isPending
const [isPending, startTransition] = useTransition()
startTransition(() => {
setX(...)
})
// isPending 在 transition 期间为 true
// 形式 B:纯函数(无 isPending)
import { startTransition } from "react"
startTransition(() => {
setX(...)
})
需要进度反馈时用形式 A,否则用形式 B。
4. 跟 Suspense 的协作(重要)
这是最容易混淆的点,也是把它用在导航场景的核心机制。
考虑:transition 里触发的更新让某个组件挂起(suspend)——比如服务端组件还在拉数据。
普通更新挂起时:
setState → 立刻渲染 → 组件 throw promise(suspend)
→ React 立刻显示最近的 Suspense fallback(loading.tsx)
→ 用户看到 spinner
→ 数据回来 → 真实内容显示
transition 更新挂起时:
startTransition(setState) → 幕后渲染 → 组件 suspend
→ React 不动声色,旧 UI 还在屏幕上
→ 数据回来,幕后树准备好
→ 一次性 commit → 旧 UI 直接换成新 UI
→ fallback 全程不显示
为什么这样设计:
- 普通更新 = 紧急的,"我现在就要看到反应",所以哪怕显示 loading 也得显示
- transition = "这个更新不那么紧急,等数据齐了再切,别给我闪 loading"
实际应用:
startTransition(() => {
router.refresh() // 触发 RSC 重新拉取,期间 server component 会挂起
})
利用这个特性可以避免 loading.tsx 的 fallback 闪烁,同时拿到 isPending 来驱动顶部进度条。
5. isPending 的精确语义
const [isPending, startTransition] = useTransition()
isPending 的生命周期:
startTransition(cb) 调用瞬间 → isPending = true
│
↓
React 在幕后渲染、可能等数据、可能 suspend
│
↓
所有挂起 resolve、新树准备好 → commit
│
↓
commit 完成 → isPending = false
特别注意:
isPending不是"fetch 在进行中"isPending是"这个 transition 引发的所有更新还没全部 commit"- 包含:渲染时间 + 挂起等待 + 真正的 commit
如果 startTransition(() => router.refresh()),isPending 涵盖:
- RSC 网络请求时间
- 服务端重新执行 server components
- 客户端 reconcile
- 最终 commit
用它做进度条,正好对应「用户感知的等待时间」。
6. 内部怎么实现(抽象层面)
React 内部用 fiber + 优先级 lane 模型:
- 每次
setState给对应的 fiber 标记一个 lane(车道) - 普通
setState→SyncLane或DefaultLane(高优) startTransition包的setState→TransitionLane(低优)- 渲染调度器(scheduler)按 lane 优先级处理
startTransition 简化版实现:
function startTransition(scope) {
const prevTransition = currentTransition
currentTransition = {
/* transition context */
}
try {
scope() // 这里调的 setState 都会带上 transition 标记
} finally {
currentTransition = prevTransition
}
}
类似「上下文标记」——把回调函数里产生的 setState 都打上「这是个 transition」的印章,调度器看到印章就放低优先级。
7. 常见陷阱
陷阱 A:异步代码里调 setState 不算 transition
startTransition(() => {
fetch("/api").then((data) => {
setData(data) // ❌ 这个 setState 不是 transition!
})
})
startTransition 只标记同步执行期间触发的 setState。.then 回调是异步的,已经脱离了 transition 上下文。
正确做法(React 19+):
startTransition(async () => {
const data = await fetch(...)
setData(data) // ✓ React 19 起异步 transition 也支持
})
陷阱 B:transition 不会让代码变快
startTransition 不缩短任何时间。它只是改变了用户感知——让旧 UI 多停留一会儿避免闪烁,同时让紧急更新插队。慢的还是慢,只是用户不那么烦躁。
陷阱 C:isPending 跨调用方不共享
每个组件的 useTransition 是独立的。组件 A 的 startTransition 不会让组件 B 的 isPending 变化。
8. 实战:进度条场景
const [isPending, startTransition] = useTransition()
// 同 tab 点击:
if (samePath) {
startTransition(() => {
router.refresh()
})
}
// 监听 isPending:
useEffect(() => {
if (isPending) start()
else done()
}, [isPending])
发生的事:
- 用户点击当前页 →
samePath为真 startTransition调用 →router.refresh()内部触发 setState(更新路由 state)→ 该 setState 被打上 transition 标记isPending = true→ useEffect 触发 →start()排 150ms 显示定时器- React 在幕后请求 RSC、等待 server components 解析
- 解析期间如果 Suspense 边界 suspend,因为是 transition,React 保留旧 UI,不显示
loading.tsx - RSC 全部解析完毕 → 一次性 commit →
isPending = false - useEffect 触发 →
done()收尾
用户看到的:旧 UI 一直保留,可能看到顶部进度条(如果耗时 > 150ms),结束后内容刷新(视觉上几乎无感,因为内容大概率没变)。
一句话总结
startTransition 把 setState 标记为「低优先级、可中断、挂起时不显示 fallback」。它不让代码变快,只让用户体验更平滑。isPending 反映这批更新从触发到完全 commit 的时间,特别适合做进度反馈。