02 · Concurrent Rendering¶
Before React 18, once React started rendering an update it ran to completion. A slow render — filtering 20,000 rows, drawing a big chart — froze the page, including the text box the user was typing in. Concurrent rendering lets React prepare an update in the background, interrupt it for something more urgent, and throw it away if it's outdated. You opt in per update with two hooks.
Urgent vs non-urgent updates¶
- Urgent: direct feedback to input — typing, clicking, pressing. Users expect these immediately.
- Non-urgent (transitions): the consequences — the filtered list, the next tab's content, the chart. A short delay is fine, as long as the input stays responsive.
useTransition¶
import { useState, useTransition } from 'react'
export default function ProductSearch({ products }) {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('')
const [isPending, startTransition] = useTransition()
function handleChange(e) {
setQuery(e.target.value) // urgent: input shows the character now
startTransition(() => {
setFilter(e.target.value) // non-urgent: list can lag behind
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <small>Updating…</small>}
<SlowList products={products} filter={filter} />
</>
)
}
- Updates inside
startTransitionare marked low priority. - If the user types again before the list finishes rendering, React abandons the in-progress render and starts over with the latest value.
isPendingistruewhile a transition is in progress — use it to dim stale content rather than blanking it.
SlowList must be meaningfully expensive for this to matter. For a demo, add an
artificial slowdown in each row:
function SlowRow({ product }) {
const start = performance.now()
while (performance.now() - start < 0.5) {} // ~0.5ms per row, demo only!
return <li>{product.name}</li>
}
With 1,000 rows that's ~500 ms per render: typing freezes without the transition, and stays smooth with it.
startTransition can also be imported directly from react when you don't need
isPending. In React 19, the function passed to startTransition may be async
(an "Action"); isPending stays true until it finishes. State updates made after an
await inside it need their own startTransition wrapper in current React versions —
check the docs for your version.
useDeferredValue¶
Sometimes you don't control the state update — the value comes in as a prop. Defer the value instead:
import { memo, useDeferredValue, useState } from 'react'
export default function Search({ products }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<Results products={products} query={deferredQuery} />
</div>
</>
)
}
const Results = memo(function Results({ products, query }) {
const q = query.toLowerCase()
return (
<ul>
{products.filter(p => p.name.toLowerCase().includes(q)).map(p => <SlowRow key={p.id} product={p} />)}
</ul>
)
})
On each keystroke React first renders with the old deferredQuery (so Results
gets the same props and memo skips it — that render is cheap), commits the input, then
renders again in the background with the new value. memo is essential here:
without it, Results would re-render during the urgent pass anyway, defeating the point.
Transitions and Suspense¶
When content that's already on screen suspends (lazy route, use(promise), suspense
query), React normally shows the nearest fallback — replacing the page with a spinner.
If the update that caused it is a transition, React instead keeps showing the old UI
until the new one is ready:
function Tabs() {
const [tab, setTab] = useState('about')
const [isPending, startTransition] = useTransition()
return (
<>
<nav style={{ opacity: isPending ? 0.7 : 1 }}>
{['about', 'posts', 'contact'].map(t => (
<button key={t} onClick={() => startTransition(() => setTab(t))}>{t}</button>
))}
</nav>
<Suspense fallback={<Spinner />}>
{tab === 'about' && <About />}
{tab === 'posts' && <Posts />} {/* suspends while loading */}
{tab === 'contact' && <Contact />}
</Suspense>
</>
)
}
Clicking "posts" keeps "about" visible (dimmed) until posts are ready — no flash of spinner. Routers use this for navigation for the same reason. (New Suspense boundaries that appear for the first time still show their fallbacks; transitions only avoid hiding content that's already revealed.)
What concurrency does not do¶
- It doesn't make your rendering code faster; it schedules it better.
- It can't interrupt a single long-running JavaScript function. React yields between components, so one component that loops for 300 ms still blocks for 300 ms.
- It doesn't apply to controlled input values: the input's own state must be urgent
(never wrap the input's
setStatein a transition). - Heavy non-rendering work (parsing a big file) belongs in a Web Worker.
Worked example: choosing between them¶
| Situation | Tool |
|---|---|
You own the setState that triggers the slow render |
useTransition |
| The slow part receives a value from a prop or a hook you don't control | useDeferredValue |
| You want a pending indicator for navigation or a tab switch | useTransition (isPending) |
| You want to show stale results dimmed while new ones render | either; compare value vs deferred value, or use isPending |
| The work is one huge synchronous computation | neither — optimise it, memoize it, or move it to a worker |
How It Actually Works¶
Every update is assigned a lane, a bit in a bitmask representing priority. Updates
from discrete events (click, keydown) get the sync lane; updates inside
startTransition get one of several transition lanes; useDeferredValue's background
re-render also uses a lower-priority lane.
React's work loop renders one fiber at a time. In concurrent mode (used for
transition lanes), after each unit of work it calls shouldYield(), which checks
whether it has used its time slice (on the order of a few milliseconds). If so, it
yields control back to the browser so input events and painting can happen, then
resumes via its scheduler (which uses a MessageChannel to schedule a new macrotask).
Sync-lane work runs without yielding.
If an urgent update arrives while a transition render is in progress, React notices a higher-priority lane is pending, pauses the transition's work-in-progress tree, renders and commits the urgent update, then restarts the transition from the new state (the old partial work is discarded because its inputs are outdated). Because the abandoned tree was never committed, no DOM changes, effects or refs from it are ever visible — which is exactly why render functions must be pure: they may run for trees that never appear.
useDeferredValue(value) works by returning the old value during urgent renders and
scheduling a transition-priority render where it returns the new value. isPending for
useTransition is itself implemented as an urgent state update (true) plus the
transition (which sets it back to false when it commits).
Common mistakes¶
- Wrapping the input's own value update in a transition → laggy or jumping typing.
useDeferredValuewithoutmemoon the expensive child.- Expecting transitions to fix a single slow function.
- Showing a full spinner during transitions instead of keeping and dimming old content.
- Using transitions for everything; urgent feedback should stay urgent.
Exercise¶
- Build a search over 5,000 generated items with the artificial
SlowRowabove and confirm typing is janky. - Fix it with
useTransition; add a pending indicator. - Fix it instead with
useDeferredValue+memo; then removememoand explain what you observe. - Record both versions in the Performance panel and compare the longest task during typing.
- Build the tab example with a lazy-loaded tab and show that the old tab stays visible when switching inside a transition but a spinner appears without it.