React useTransition and useDeferredValue: Concurrent React (2026)
React 18's concurrent features let you mark certain state updates as non-urgent, keeping the UI responsive while expensive rendering happens in the background. useTransition and useDeferredValue are the two hooks for this — they look similar but serve different purposes. Understanding when to use each one is key to writing performant concurrent React.
Table of Contents
The Concurrent Rendering Mental Model
Without concurrent features, React renders synchronously — every state update blocks the main thread until the entire component tree re-renders. A 200ms render freezes the UI completely.
With concurrent rendering, React can:
- Interrupt a render to process a higher-priority update (e.g. user typing)
- Defer an expensive render until the browser is idle
- Show stale content while new content prepares, rather than a loading spinner
useTransition
useTransition marks state updates inside its callback as transitions — low priority updates that can be interrupted:
'use client'
import { useState, useTransition } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
// Urgent: update the input immediately
setQuery(value)
// Non-urgent: defer the expensive search results update
startTransition(() => {
const filtered = heavyFilter(allItems, value)
setResults(filtered)
})
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{/* Show visual hint while transition is pending */}
<div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.2s' }}>
<ResultsList results={results} />
</div>
{isPending && <span className="text-sm text-gray-500">Updating...</span>}
</div>
)
}
Tab switching — common useTransition pattern:
function TabContainer() {
const [activeTab, setActiveTab] = useState('overview')
const [isPending, startTransition] = useTransition()
function selectTab(tab) {
startTransition(() => setActiveTab(tab))
}
return (
<div>
<nav>
{['overview', 'analytics', 'settings'].map(tab => (
<button
key={tab}
onClick={() => selectTab(tab)}
style={{ fontWeight: activeTab === tab ? 'bold' : 'normal' }}
>
{tab}
</button>
))}
</nav>
{/* Tab content dims while new tab is preparing */}
<div style={{ opacity: isPending ? 0.7 : 1 }}>
{activeTab === 'overview' && <SlowOverview />}
{activeTab === 'analytics' && <SlowAnalytics />}
{activeTab === 'settings' && <Settings />}
</div>
</div>
)
}
startTransition (without hook)
Use the imported startTransition when you don't need isPending — e.g. in event handlers outside components:
import { startTransition } from 'react'
// In a router, store, or utility function
function navigateTo(path) {
startTransition(() => {
setCurrentPath(path) // Mark navigation as a transition
})
}
// In React 19, actions are automatically treated as transitions
async function submitForm(formData) {
// React 19: async functions passed to form action are transitions
await saveToServer(formData)
setSuccess(true)
}
useDeferredValue
useDeferredValue defers re-rendering a component when its props change — React renders with the old value first (instant) then schedules the re-render with the new value when idle:
'use client'
import { useState, useDeferredValue, memo } from 'react'
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query)
// isStale: true while deferred value lags behind current value
const isStale = query !== deferredQuery
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{/* Only re-renders when deferredQuery changes */}
<ExpensiveList filter={deferredQuery} />
</div>
)
}
// Memoize the expensive component — only re-renders when deferredQuery changes
const ExpensiveList = memo(function({ filter }: { filter: string }) {
// Simulate expensive filtering
const items = allItems.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
)
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
})
// Parent — input stays responsive, list updates are deferred
function SearchPage() {
const [query, setQuery] = useState('')
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<SearchResults query={query} />
</div>
)
}
useTransition vs useDeferredValue
| Feature | useTransition | useDeferredValue |
|---|---|---|
| You control | Which state updates are deferred | Which value update is deferred |
| Where it lives | Where the state is updated (event handler) | Where the value is consumed (receiving component) |
| isPending | Yes — built in | Manual: value !== deferredValue |
| Use when | You own the state update (same component) | You receive a prop/value from a parent |
| Typical use case | Tab switching, navigation, filter toggles | Live search lists, real-time visualizations |
Practical Patterns
Optimistic UI with useTransition:
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
const [isPending, startTransition] = useTransition()
function handleLike() {
// Optimistic: update immediately
setLikes(l => l + 1)
startTransition(async () => {
try {
await apiLikePost(postId)
} catch {
// Rollback on error
setLikes(l => l - 1)
}
})
}
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {likes}
</button>
)
}
Deferred chart rendering:
function Dashboard({ dateRange }) {
const deferredRange = useDeferredValue(dateRange)
const isUpdating = dateRange !== deferredRange
return (
<div>
<DateRangePicker value={dateRange} />
<div style={{ filter: isUpdating ? 'blur(2px)' : 'none', transition: 'filter 0.2s' }}>
<HeavyChart range={deferredRange} />
</div>
</div>
)
}
With Suspense
function App() {
const [tab, setTab] = useState('home')
const [isPending, startTransition] = useTransition()
// With Suspense: startTransition prevents Suspense from showing
// the fallback when switching tabs — it stays on the old tab
// until the new one is ready, then switches instantly
return (
<div>
<TabBar onSelect={tab => startTransition(() => setTab(tab))} isPending={isPending} />
<Suspense fallback={<Spinner />}>
{tab === 'home' && <Home />}
{tab === 'profile' && <Profile />}
</Suspense>
</div>
)
}
Next.js and Server Actions
'use client'
import { useTransition } from 'react'
import { updateProfile } from '@/actions/profile'
function ProfileForm({ profile }) {
const [isPending, startTransition] = useTransition()
function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.target)
startTransition(async () => {
// Server Action inside a transition — React tracks pending state
await updateProfile(formData)
})
}
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue={profile.name} />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</button>
</form>
)
}