menu
首页文章相册工具关于
search
...

深入理解 React startTransition

2026/04/29
eye-

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 涵盖:

  1. RSC 网络请求时间
  2. 服务端重新执行 server components
  3. 客户端 reconcile
  4. 最终 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])

发生的事:

  1. 用户点击当前页 → samePath 为真
  2. startTransition 调用 → router.refresh() 内部触发 setState(更新路由 state)→ 该 setState 被打上 transition 标记
  3. isPending = true → useEffect 触发 → start() 排 150ms 显示定时器
  4. React 在幕后请求 RSC、等待 server components 解析
  5. 解析期间如果 Suspense 边界 suspend,因为是 transition,React 保留旧 UI,不显示 loading.tsx
  6. RSC 全部解析完毕 → 一次性 commit → isPending = false
  7. useEffect 触发 → done() 收尾

用户看到的:旧 UI 一直保留,可能看到顶部进度条(如果耗时 > 150ms),结束后内容刷新(视觉上几乎无感,因为内容大概率没变)。

一句话总结

startTransition 把 setState 标记为「低优先级、可中断、挂起时不显示 fallback」。它不让代码变快,只让用户体验更平滑。isPending 反映这批更新从触发到完全 commit 的时间,特别适合做进度反馈。

一些有用的 Git 命令使用 React 创建待办事项列表
目录
  • 1. 它要解决什么问题
  • 2. 跟普通 setState 的本质区别
  • 3. 两种用法
  • 4. 跟 Suspense 的协作(重要)
  • 5. isPending 的精确语义
  • 6. 内部怎么实现(抽象层面)
  • 7. 常见陷阱
  • 陷阱 A:异步代码里调 setState 不算 transition
  • 陷阱 B:transition 不会让代码变快
  • 陷阱 C:isPending 跨调用方不共享
  • 8. 实战:进度条场景
  • 一句话总结