startTransitionis one of the core concurrent features introduced in React 18. This post walks through it from design motivation to internal mechanics.
1. What problem does it solve?
In React 16/17, every setState had the same priority — synchronous, immediate commit. That created a dilemma:
function Search() {
const [input, setInput] = useState("")
const [results, setResults] = useState([])
const onChange = (e) => {
setInput(e.target.value) // must respond instantly
setResults(filterHugeList(e.target.value)) // slow but not urgent
}
}
Typing in the input must respond instantly (it's the user's visual feedback), but filtering 10,000 items can be deferred. When both setStates are batched together, the slow one drags the whole event down and the input feels janky.
React 18 introduced the concept of priority: urgent updates and transitions. startTransition marks the setStates in its callback as transitions:
const onChange = (e) => {
setInput(e.target.value) // urgent, commits immediately
startTransition(() => {
setResults(filterHugeList(...)) // transition, interruptible
})
}
2. The fundamental difference vs regular setState
It's not about "delaying execution" — it's about interruptible rendering.
Regular update:
setState → React runs the full render synchronously → DOM updated
↑ user must wait until here
Transition update:
startTransition → marks a batch of setStates as low-priority
→ React starts rendering the new tree (in memory)
→ if an urgent update arrives → pause transition, handle urgent first
→ after urgent → resume transition (may restart)
→ eventually new tree is ready → commit
Key points:
- Transition rendering happens in the background, doesn't block user interaction
- It can be interrupted, discarded, or restarted
- The old UI stays on screen until the new UI is ready, then atomically swaps
3. Two ways to use it
// Form A: with isPending
const [isPending, startTransition] = useTransition()
startTransition(() => {
setX(...)
})
// isPending is true during the transition
// Form B: standalone (no isPending)
import { startTransition } from "react"
startTransition(() => {
setX(...)
})
Use Form A when you need progress feedback, otherwise Form B.
4. Cooperation with Suspense (important)
This is the most commonly misunderstood point — and the core mechanic for navigation use cases.
Scenario: an update inside a transition causes a component to suspend (e.g., a server component is fetching data).
When a regular update suspends:
setState → renders immediately → component throws promise (suspend)
→ React shows the nearest Suspense fallback (loading.tsx)
→ user sees a spinner
→ data arrives → real content renders
When a transition update suspends:
startTransition(setState) → background render → component suspends
→ React stays quiet, old UI remains on screen
→ data arrives, background tree is ready
→ one-shot commit → old UI swaps to new UI directly
→ fallback is never shown
Why this design:
- Regular update = urgent, "I want a reaction NOW", so even a loading state must show
- Transition = "this isn't urgent, wait until data is ready before swapping, don't flash a loading state"
Real-world application:
startTransition(() => {
router.refresh() // triggers RSC refetch; server components will suspend
})
This leverages the behavior to avoid loading.tsx fallback flicker while getting isPending to drive a top progress bar.
5. The precise semantics of isPending
const [isPending, startTransition] = useTransition()
Lifecycle of isPending:
startTransition(cb) called → isPending = true
│
↓
React renders in background, may wait for data, may suspend
│
↓
all suspensions resolve, new tree ready → commit
│
↓
commit done → isPending = false
Important:
isPendingis not "fetch in flight"isPendingis "all updates triggered by this transition haven't been fully committed yet"- It includes: render time + suspension waits + actual commit
For startTransition(() => router.refresh()), isPending covers:
- RSC network request time
- Server re-executing server components
- Client-side reconciliation
- Final commit
It maps neatly to "user-perceived wait time" — perfect for a progress bar.
6. How it works internally (abstract level)
React uses a fiber + priority lane model:
- Each
setStatetags its fiber with a lane - Regular
setState→SyncLaneorDefaultLane(high priority) setStateinsidestartTransition→TransitionLane(low priority)- The scheduler processes lanes by priority
Simplified startTransition implementation:
function startTransition(scope) {
const prevTransition = currentTransition
currentTransition = {
/* transition context */
}
try {
scope() // setStates called here are tagged as transitions
} finally {
currentTransition = prevTransition
}
}
Think of it as a context marker — every setState triggered inside the callback gets stamped with "this is a transition", and the scheduler treats stamped updates as low-priority.
7. Common pitfalls
Pitfall A: setStates in async code aren't transitions
startTransition(() => {
fetch("/api").then((data) => {
setData(data) // ❌ this setState is NOT a transition!
})
})
startTransition only marks setStates called synchronously within the callback. The .then callback runs asynchronously and has already escaped the transition context.
Correct way (React 19+):
startTransition(async () => {
const data = await fetch(...)
setData(data) // ✓ React 19 supports async transitions
})
Pitfall B: Transitions don't make your code faster
startTransition doesn't shorten any time. It only changes perception — keeping the old UI visible longer to avoid flicker, while letting urgent updates jump the queue. Slow stays slow; users just feel less annoyed.
Pitfall C: isPending isn't shared across callers
Each component's useTransition is independent. Component A's startTransition doesn't affect Component B's isPending.
8. Real-world example: progress bar on navigation
const [isPending, startTransition] = useTransition()
// Same-page click:
if (samePath) {
startTransition(() => {
router.refresh()
})
}
// Watch isPending:
useEffect(() => {
if (isPending) start()
else done()
}, [isPending])
What happens:
- User clicks the current page link →
samePathis true startTransition→router.refresh()triggers internal setState (updates router state) → that setState is tagged as transitionisPending = true→ useEffect fires →start()schedules a 150ms display timer- React fetches RSC in the background, waits for server components to resolve
- If a Suspense boundary suspends during this, because it's a transition, React keeps the old UI and doesn't show
loading.tsx - All RSC resolves → one-shot commit →
isPending = false - useEffect fires →
done()finishes
What the user sees: old UI stays visible, possibly a top progress bar (if the operation takes >150ms), then content refreshes (visually almost imperceptible since content likely didn't change).
TL;DR
startTransition marks setStates as low-priority, interruptible, and fallback-suppressing during suspension. It doesn't make code faster — it makes the UX smoother. isPending reflects the time from trigger to full commit, making it ideal for progress feedback.