useTransition Hook
React 18 introduced concurrent rendering — the ability to interrupt, pause, and resume rendering work. useTransition is the primary hook that lets your components opt into this power by telling React which state updates are non-urgent and can be interrupted by more important work like user input.
The Problem: Slow Updates Block the UI
Imagine a search input that filters a list of 10 000 items. Every keystroke triggers a state update, React re-renders the huge list, and the input itself freezes until rendering finishes. The user sees stuttering and lag — a broken experience.
// ✗ PROBLEMATIC — every keystroke re-renders the full list synchronously
function SlowSearch() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
function handleChange(e) {
setQuery(e.target.value) // urgent: must update input immediately
setResults(filterItems(e.target.value)) // slow: filters 10 000 items
}
return (
<>
<input value={query} onChange={handleChange} />
<ItemList items={results} /> {/* expensive render */}
</>
)
}Both state updates run synchronously in the same batch. React cannot commit the input update without also finishing the expensive list render — so the input lags.
useTransition: Marking Updates as Non-Urgent
useTransition returns a tuple of two values:
isPending— a boolean that istruewhile the transition is in progressstartTransition— a function that wraps the non-urgent state update
import { useState, useTransition } from 'react'
function SmootherSearch() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
setQuery(value) // urgent — runs immediately
startTransition(() => {
setResults(filterItems(value)) // non-urgent — can be interrupted
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results…</p>}
<ItemList items={results} style={{ opacity: isPending ? 0.6 : 1 }} />
</>
)
}Now setQuery runs first and React commits the input update immediately. The expensive setResults is deferred. If the user types another character before setResults finishes, React abandons the in-progress render and starts fresh with the latest query — no stale results, no frozen UI.
Showing a Loading Indicator with isPending
The isPending flag is true for the entire duration of the transition — from when startTransition is called until React finishes rendering the deferred update. Use it to give users visual feedback:
function TabContainer() {
const [activeTab, setActiveTab] = useState('home')
const [isPending, startTransition] = useTransition()
function selectTab(tab) {
startTransition(() => {
setActiveTab(tab)
})
}
return (
<div>
<nav>
{['home', 'about', 'posts'].map(tab => (
<button
key={tab}
onClick={() => selectTab(tab)}
style={{
fontWeight: activeTab === tab ? 'bold' : 'normal',
opacity: isPending ? 0.7 : 1,
}}
>
{tab}
</button>
))}
</nav>
{isPending ? (
<div className="spinner" aria-label="Loading…" />
) : (
<TabContent tab={activeTab} />
)}
</div>
)
}Notice that the active tab button updates immediately on click (because activeTab itself is set inside the transition and React keeps the old UI visible while preparing the new one). The spinner only appears while the heavy TabContent is rendering.
A Complete Slow-List Demo
import { useState, useTransition, memo } from 'react'
// Artificially slow item — each renders 1ms of blocking work
const SlowItem = memo(function SlowItem({ text }) {
const startTime = performance.now()
while (performance.now() - startTime < 1) {} // block for 1ms
return <li>{text}</li>
})
function generateItems(query) {
return Array.from({ length: 500 }, (_, i) => `Item ${i + 1}: ${query}`)
}
export default function FilterDemo() {
const [query, setQuery] = useState('')
const [items, setItems] = useState(() => generateItems(''))
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
setQuery(value)
startTransition(() => {
setItems(generateItems(value))
})
}
return (
<div>
<input
placeholder="Type to filter…"
value={query}
onChange={handleChange}
style={{ fontSize: 18, padding: 8 }}
/>
<p>
Status:{' '}
<strong>{isPending ? 'Rendering list…' : 'Up to date'}</strong>
</p>
<ul style={{ opacity: isPending ? 0.5 : 1 }}>
{items.map((item, i) => (
<SlowItem key={i} text={item} />
))}
</ul>
</div>
)
}useTransition vs Suspense
useTransition and Suspense are complementary, not competing:
Feature | useTransition | Suspense |
|---|---|---|
What it handles | CPU-bound state updates (re-renders) | Data fetching / async resources |
Trigger | You call startTransition() | A component throws a Promise |
Loading UI | isPending flag — you control it | fallback prop on nearest <Suspense> |
Shows old UI? | Yes — old UI stays visible until ready | Yes — in transition mode |
React 18 only? | Yes | Available since React 16, enhanced in 18 |
They work together beautifully: when you navigate to a tab that fetches data wrapped in Suspense, wrap the setActiveTab call in startTransition so the old tab stays visible while both the re-render and the data fetch complete.
Common Use Cases
Tab switching — keep the current tab visible while the next tab renders
Search / filter — let the input respond instantly; defer the heavy list update
Page-level navigation — keep the current page visible during the next page render
Large form resets — instantly clear the form fields; defer resetting downstream state
Chart / data visualization updates — keep old chart visible while new one renders
useTransition in React Router / Next.js
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
function NavLink({ href, children }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
function navigate() {
startTransition(() => {
router.push(href)
})
}
return (
<button onClick={navigate} disabled={isPending}>
{isPending ? 'Loading…' : children}
</button>
)
}Key Takeaways
useTransitionmarks a state update as low-priority so React can interrupt it for more urgent workThe input (or any urgent UI) stays responsive; only the deferred part may lag
isPendinglets you show a loading indicator for the duration of the transitionTransitions are for CPU-bound rendering work — use Suspense for data-fetching async work
If the user triggers another transition before the first finishes, React discards the in-progress render