Concurrent Mode and Transitions
The Responsiveness Problem
You know that feeling when you click a tab and nothing happens? The new tab's content requires rendering 500 complex components. Without concurrent features, the entire render blocks the main thread. The tab indicator doesn't update. The cursor doesn't blink. Scroll is dead. For 300ms, your app might as well not exist.
// Without transitions: clicking the tab freezes the UI
function TabContainer() {
const [activeTab, setActiveTab] = useState('overview');
return (
<>
<TabBar
active={activeTab}
onChange={tab => setActiveTab(tab)} // Blocks UI for 300ms
/>
<TabContent tab={activeTab} />
</>
);
}
The user clicks "Analytics" and nothing happens for 300ms. Then everything updates at once. This feels broken, even though it's technically correct.
The Mental Model
Think of concurrent rendering as working on a draft email while answering phone calls. Without concurrency, you must finish the email before picking up the phone — even if it rings mid-sentence. The caller waits. With concurrency, you can pause the email mid-sentence, answer the phone, then resume the email afterward.
startTransition tells React: "This update is like writing the email — it's important but not urgent. If the phone rings (a user interaction), pause the email and answer the phone first."
The "phone call" is any synchronous update (click, type, submit). The "email" is the transition render. React works on the transition in the background, yielding the main thread for urgent interactions.
useTransition: The API
function TabContainer() {
const [activeTab, setActiveTab] = useState('overview');
const [isPending, startTransition] = useTransition();
function handleTabChange(tab) {
startTransition(() => {
setActiveTab(tab); // This render is now interruptible
});
}
return (
<>
<TabBar
active={activeTab}
onChange={handleTabChange}
loading={isPending} // Show loading indicator while transitioning
/>
{isPending ? (
<TabSkeleton />
) : (
<TabContent tab={activeTab} />
)}
</>
);
}
useTransition returns:
isPending:truewhile the transition is rendering. Use for loading indicators.startTransition: Wraps state updates to mark them as transitions (lower priority, interruptible).
When the user clicks a tab:
- The click handler calls
startTransition(() => setActiveTab('analytics')) - React immediately sets
isPendingtotrue— the skeleton shows - React starts rendering the new tab content concurrently (on TransitionLane)
- If the user clicks a different tab before rendering finishes, React discards the in-progress work and starts the new transition
- When the render completes, React commits it and sets
isPendingtofalse
startTransition vs useTransition
There are two ways to create transitions:
import { startTransition, useTransition } from 'react';
// useTransition — in components, gives you isPending
function SearchPage() {
const [isPending, startTransition] = useTransition();
// isPending lets you show loading UI
}
// startTransition — anywhere (event handlers, effects, outside React)
function handleSearch(query) {
startTransition(() => {
setResults(search(query));
});
// No isPending — you need to track loading state yourself
}
Use useTransition when you need a loading indicator. Use startTransition when you just want the update to be interruptible.
What "Interruptible" Really Means
During a concurrent render, React processes fiber nodes one at a time. Between each node, it checks shouldYield(). If 5ms have elapsed, React pauses:
Timeline during a transition render:
|--React renders 5ms--|--Browser event + paint--|--React renders 5ms--|--Browser--|...
30 fibers processed Click handled, repaint 30 more fibers
If a synchronous update arrives during a transition render:
|--Transition renders--| CLICK |--Sync render + commit--|--Transition restarts--|
Partial work ^ Tab click handled Discarded work restarted
Interrupts
The transition work is discarded, not paused. React doesn't save partially rendered state — it starts the transition render from scratch after the synchronous update commits.
Because transitions restart from scratch when interrupted, your component functions might be called more times than you expect. If a user types 5 characters quickly while a transition is rendering, the transition restarts 5 times. Each restart calls your component function again. This is why render must be pure — side effects in render would fire for every restart.
Concurrent UI Patterns
Pattern 1: Show Previous While Loading
By default, transitions keep showing the old UI while the new UI renders in the background:
function SearchResults({ query }) {
const results = use(fetchResults(query)); // Suspense-enabled fetch
return (
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
);
}
function SearchPage() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
return (
<>
<input
value={query}
onChange={e => {
// Typing is synchronous (immediate feedback)
// But the results update is a transition
startTransition(() => {
setQuery(e.target.value);
});
}}
/>
<Suspense fallback={<Skeleton />}>
{/* While the transition is pending, React shows the OLD results */}
{/* not the Suspense fallback */}
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<SearchResults query={query} />
</div>
</Suspense>
</>
);
}
Without transition: typing triggers a new fetch, Suspense shows the skeleton, and the old results disappear. Jarring.
With transition: typing triggers a new fetch, but React keeps showing the old results (dimmed with isPending) until new results are ready. Smooth.
Pattern 2: Optimistic Navigation
function Router() {
const [page, setPage] = useState('/home');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
return (
<>
<NavBar
loading={isPending}
onNavigate={navigate}
/>
<Suspense fallback={<PageSkeleton />}>
<Page url={page} />
</Suspense>
</>
);
}
The nav bar link clicks are immediate — the active link indicator updates synchronously. The page content transitions in the background. If the user rapidly clicks between pages, each click interrupts the previous transition.
Production Scenario: The Autocomplete That Feels Instant
A team builds an autocomplete search that filters 50,000 items client-side:
function Autocomplete({ items }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // Sync: input updates immediately
startTransition(() => {
// Transition: filtering 50K items and re-rendering the list
const filtered = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <div className="loading-bar" />}
<ul>
{filteredItems.map(item => (
<SearchResult key={item.id} item={item} />
))}
</ul>
</>
);
}
The input always feels responsive because setQuery is synchronous. The list update is a transition — it renders in the background and doesn't block typing. If the user types another character before the list finishes rendering, the old transition is interrupted and a new one starts with the updated query.
How React avoids showing stale content
When a transition is interrupted and restarted, React doesn't commit the incomplete render. The current tree (what's on screen) stays intact. The user sees the old valid content until the new content is fully ready.
This is different from debouncing. With debouncing, you delay the start of work. With transitions, work starts immediately but can be interrupted and restarted. The user gets results as fast as possible — no artificial delay — but never sees an incomplete or inconsistent UI.
Transitions are also different from setTimeout. A setTimeout delays execution by a fixed time regardless of whether the CPU is free. A transition starts immediately and only defers to higher-priority work. On a fast device with no competing work, a transition commits almost instantly.
Common Mistakes
-
Wrong: Wrapping the input's own state update in startTransition Right: Keep the input update synchronous. Only wrap the derived/expensive update in transition
-
Wrong: Using transitions for everything to make the app faster Right: Use transitions only for updates where showing stale content temporarily is acceptable
-
Wrong: Expecting isPending to be true immediately inside the event handler Right: isPending updates asynchronously in the next render cycle
-
Wrong: Assuming transitions prevent unnecessary renders Right: Transitions change priority, not whether renders happen. Components still re-render
Challenge
Design the transition boundary
Quiz
Key Rules
- 1startTransition marks state updates as low-priority and interruptible. The UI stays responsive because React yields to the browser between fiber nodes.
- 2useTransition returns isPending (boolean) for showing loading indicators, plus the startTransition function.
- 3Transitions keep showing old content while new content renders. They avoid hiding already-visible content behind Suspense fallbacks.
- 4Interrupted transitions restart from scratch — they don't resume. Render must be pure because it may run multiple times.
- 5Synchronous updates (outside startTransition) always take priority and interrupt in-progress transitions.
- 6Transitions don't make renders faster — they make renders non-blocking. Use memoization (React.memo, useMemo) to reduce total render work.