React Concurrent Features: startTransition, useDeferredValue, and When to Actually Reach for Them
React 18+ lets you mark state updates as non-urgent so expensive re-renders stop blocking inputs. Here's exactly when to use startTransition, useTransition, and useDeferredValue.
React Concurrent Features: startTransition, useDeferredValue, and When to Actually Reach for Them
You have a search input. It filters a list of 5,000 items in real time. You type fast, and the input lags — each keystroke takes 150ms to register because React is busy re-rendering the entire list before it can update the cursor position.
This isn't a React bug. It's the consequence of treating all state updates as equally urgent. When you call setState, React has no idea whether you're updating a text input (must feel instant) or a 5,000-item list (can wait a frame or two). By default, it treats everything as urgent and blocks the whole thread until the render is done.
Concurrent features let you tell React which updates can wait.
What "Concurrent" Actually Means
The concurrent renderer (React 18 and later) can interrupt, pause, and resume renders. It doesn't reprioritise anything on its own — you opt in by marking specific updates as non-urgent. What it does change for everyone, whether you opt in or not, is that createRoot batches every update including those inside promises and timeouts, and Suspense behaves differently than it did on React 17. Only the prioritisation is opt-in.
Two lanes:
- Urgent: typing, button clicks, anything that must feel synchronous
- Transition: state updates that drive heavy re-renders where a slight delay is acceptable
When React has both kinds of work queued, urgent work goes first. If a new urgent update arrives while a transition render is in progress, React discards the in-progress work and handles the urgent update immediately.
This is what makes typing feel responsive even when the list behind it is expensive.
The Problem in Code
Here's the classic freeze scenario:
function ProductSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState(products);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
// Synchronous filter over 5,000 items — blocks the input
setResults(
products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
}
return (
<>
<input value={query} onChange={handleChange} placeholder="Search..." />
<ProductList items={results} />
</>
);
}React 18 batches these two setState calls and renders once. But that one render includes ProductList with 5,000 items, and it runs synchronously. The input update is held hostage to the list update. The user types 'r', waits 150ms, types 'e', waits 150ms. The input feels broken.
startTransition: The Blunt Instrument
startTransition tells React that the updates inside it are non-urgent:
import { startTransition } from 'react';
function ProductSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState(products);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Urgent — updates the input immediately
setQuery(value);
// Non-urgent — React will render this when it has time
startTransition(() => {
setResults(
products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="Search..." />
<ProductList items={results} />
</>
);
}The input now updates immediately on every keystroke. The list update is scheduled as a transition — React works on it in the background, but if you type another character before it finishes, React throws away the in-progress render and starts fresh with the latest query.
startTransition is a bare function you import from React. It gives you zero feedback about whether the transition is still running. If you need that, use useTransition.
useTransition: With a Loading State
import { useTransition } from 'react';
function ProductSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState(products);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setResults(
products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="Search..." />
<div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.2s' }}>
<ProductList items={results} />
</div>
{isPending && <span className="text-sm text-muted">Updating...</span>}
</>
);
}isPending is true from the moment you call startTransition until React commits the transition render. Use it to dim stale content, show a spinner, or disable related UI. The old results stay visible while the new render is in progress — no blank state, no flash.
Dimming old content with opacity is often better than showing a spinner. It signals "this is updating" without hiding the context the user is reading.
How React Handles the Two Lanes
Here's what happens internally when you mix urgent and transition updates:
React maintains two trees: the current tree (what's on screen) and the work-in-progress tree (the new render). Urgent updates go straight to the DOM. Transition renders happen in the work-in-progress tree and get thrown away if something more urgent interrupts.
This is why the input feels responsive. Transition work can always be interrupted.
useDeferredValue: When You Don't Own the Setter
useTransition requires you to wrap the state setter. Sometimes you can't — the value comes from a prop, from a parent component's state, or from a third-party hook you don't control.
useDeferredValue takes a value and returns a deferred copy that lags behind during transitions:
import { useDeferredValue, useMemo } from 'react';
// This component receives query as a prop — it doesn't own the state
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() =>
products.filter(p =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[deferredQuery]
);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<ProductList items={results} />
</div>
);
}While React is working on updating the deferred value, deferredQuery holds the previous value. The useMemo depends on deferredQuery, so it doesn't recompute until the deferred value catches up. Comparing query !== deferredQuery gives you the same signal as isPending. Measured, the render sequence for (query, deferredQuery) going from "a" to "ab" is ("a","a") → ("ab","a") → ("ab","ab"): one render where the two disagree, which is exactly the window your isStale styling covers.
On the very first render there is no previous value to fall back to, so deferredQuery equals query and nothing is deferred. React 19 adds a second argument for that case — useDeferredValue(query, '') returns '' on the initial render and then upgrades to the real value, which lets you show an empty state instead of computing over the full list on mount.
useDeferredValue and useTransition are two interfaces for the same underlying mechanism. Choose based on what you have access to:
useTransition | useDeferredValue | |
|---|---|---|
| You own the state setter | Yes | No |
| Works with external/prop values | No | Yes |
| Gives loading feedback | isPending | value !== deferredValue |
| Where to wrap | State setter call | The value itself |
Tabs: The Other Classic Use Case
Tab navigation is the other place these hooks shine. Without a transition, clicking a tab that renders a heavy component causes a noticeable delay where the old tab disappears and nothing has rendered yet:
type Tab = 'overview' | 'analytics' | 'settings';
function Dashboard() {
const [tab, setTab] = useState<Tab>('overview');
const [isPending, startTransition] = useTransition();
function switchTab(next: Tab) {
startTransition(() => setTab(next));
}
return (
<div>
<nav className="flex gap-2">
{(['overview', 'analytics', 'settings'] as Tab[]).map(t => (
<button
key={t}
onClick={() => switchTab(t)}
className={`tab ${tab === t ? 'tab-active' : ''} ${
isPending ? 'opacity-60' : ''
}`}
>
{t}
</button>
))}
</nav>
{/* Analytics tab has heavy charts — render is slow */}
<TabContent tab={tab} />
</div>
);
}With startTransition, clicking "Analytics" immediately highlights the tab button. The Analytics content renders in the background. If the user changes their mind and clicks "Settings" before "Analytics" is done, React discards the analytics render and starts on settings. The UI always acknowledges the click immediately.
React 19: Async Transitions
React 19 extends transitions to support async functions. This is what they call "Actions":
function SaveDraftButton({ postId, content }: { postId: string; content: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleSave() {
startTransition(async () => {
try {
await saveDraft(postId, content);
// State updates after the await are still part of the transition
setError(null);
} catch (e) {
setError('Save failed — check your connection.');
}
});
}
return (
<>
<button onClick={handleSave} disabled={isPending}>
{isPending ? 'Saving...' : 'Save Draft'}
</button>
{error && <p className="text-red-500">{error}</p>}
</>
);
}In React 18, startTransition with an async function would complete the transition immediately — it didn't wait for the promise. React 19 fixes this: async transitions stay pending until the entire async chain resolves. isPending is true for the full duration of the save, not just until the first await.
Mounted on React 19.2.3 with a component that sets state before an await, awaits a promise you control, then sets state after it. The render timeline, as (isPending, rendered value):
(false, "idle") initial mount
(true, "idle") startTransition called — pending, old value still on screen
(true, "after-await") promise resolved
(false, "after-await") action complete
Two things to take from that. isPending really does span the whole await. And the pre-await setState never reached the screen on its own — React held it and committed it together with the post-await update, so both state changes inside the action landed in a single visible step. That's what you want for a save button, and it's worth knowing before you try to drive a multi-stage progress indicator from state updates inside one action.
This pairs well with React 19's useActionState and form actions if you're building forms.
In React 18, wrapping a fetch call in startTransition does NOT make the transition wait for the response. isPending goes false as soon as the function returns. Use React 19 async transitions or Suspense for data-fetching use cases.
When NOT to Use These
The temptation, once you find these hooks, is to wrap everything in startTransition. That's a mistake.
Don't defer fast renders. If the update already lands inside a frame, there's no visible jank to fix and you've added complexity for zero gain. The Profiler tells you which it is.
Don't treat this as a substitute for real optimization. If you're rendering 5,000 full DOM nodes, startTransition makes the experience slightly less bad but doesn't fix the root problem. Windowing (@tanstack/react-virtual, react-window) makes it actually fast. Use both when needed — startTransition for responsiveness while your virtualized list is re-rendering, virtualization to make the render fast.
Never defer the input value itself. The whole point is that input updates stay urgent. If you accidentally wrap your input's setState in a transition, you've made the input laggy. That's the opposite of the goal:
// Wrong — you've just made your input slow
startTransition(() => {
setQuery(e.target.value); // This should be urgent, not a transition
});Don't use it when updates need to be synchronous. Payment confirmations, form submissions with validation that must complete before navigation — these need to be immediate and predictable. A transition that could be interrupted mid-way through is not what you want.
The Decision Flow
Does your state update drive an expensive re-render?
→ No: don't bother, just use useState normally
→ Yes: does it make the UI feel sluggish?
→ No: profile first, premature optimization
→ Yes: do you own the state setter?
→ Yes: useTransition + startTransition
→ No (prop / external): useDeferredValue
Profile before you reach for these. React DevTools Profiler will show you which components are slow and how long renders take. If a render is taking 150ms and that 150ms is blocking your input, that's the signal.
Concurrent features are prioritization tools. React still does the same amount of work — it just reorders it so the urgent stuff gets through first. If you need less work done, that's a different problem with different solutions.
Comments (0)
No comments yet. Be the first to share your thoughts!