In one minute
At 60 frames per second, the browser gets a new frame every ~16 ms. A render that takes 125 ms freezes the page for 125 ms: the letter you just typed doesn’t even appear. Concurrent rendering lets React split a low-priority render into small pieces, pause to let the browser handle input and paint, and throw the render away if newer input arrives. You tell React what’s low-priority with useTransition (around a state update) or useDeferredValue (around a value). The work doesn’t get smaller, it stops blocking. The catch with useDeferredValue: the slow component must be memoized, or React renders it anyway during the urgent render.
You’ll be able to: keep an input responsive over a slow list with useDeferredValue and memo, and explain why it does nothing without memo.
1function ProductGrid({ query }) {2 const results = catalog.filter(matches(query)).slice(0, 120)3 return results.map(p => <SlowCard key={p.id} product={p} />)4}5function SlowCard({ product }) {6 burn(1) // ~1 ms each: a stand-in for an expensive card7 return <li>{product.name}</li>8}910function Search() {11 const [query, setQuery] = useState('')12 return (<>13 <input value={query} onChange={e => setQuery(e.target.value)} />14 <ProductGrid query={query} />15 </>)16}- event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “c”. - render phase
<ProductGrid query={query} />One synchronous render with the input and all 120 slow cards. Nothing can interrupt it. - browserOnly now can the browser show the typed letter. Recorded: next frame after about 125–135 ms, on every key.
1function ProductGrid({ query }) {2 const results = catalog.filter(matches(query)).slice(0, 120)3 return results.map(p => <SlowCard key={p.id} product={p} />)4}5function SlowCard({ product }) {6 burn(1) // ~1 ms each: a stand-in for an expensive card7 return <li>{product.name}</li>8}910function Search() {11 const [query, setQuery] = useState('')12 const deferredQuery = useDeferredValue(query)13 return (<>14 <input value={query} onChange={e => setQuery(e.target.value)} />15 <ProductGrid query={deferredQuery} />16 </>)17}- event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “c”. - render phase
const deferredQuery = useDeferredValue(query)In the urgent render,deferredQueryis still the old value,"". - render phase
<ProductGrid query={deferredQuery} />ButProductGridisn’t memoized, so it renders anyway, all 120 slow cards, for a query it had already shown. - browserRecorded: no better than plain, about 125 ms per key. Then the background render does the real work: the grid rendered twice per key.
1function ProductGrid({ query }) {2 const results = catalog.filter(matches(query)).slice(0, 120)3 return results.map(p => <SlowCard key={p.id} product={p} />)4}5function SlowCard({ product }) {6 burn(1) // ~1 ms each: a stand-in for an expensive card7 return <li>{product.name}</li>8}910const MemoGrid = memo(ProductGrid)1112function Search() {13 const [query, setQuery] = useState('')14 const deferredQuery = useDeferredValue(query)15 return (<>16 <input value={query} onChange={e => setQuery(e.target.value)} />17 <MemoGrid query={deferredQuery} />18 </>)19}- event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “c”. - render phase
const deferredQuery = useDeferredValue(query)The urgent render givesMemoGridthe same oldquery, somemoskips it. This render only updates the input. - browserRecorded: the next frame came within about one frame (~16 ms).
- render phase
<MemoGrid query={deferredQuery} />Then the grid renders for “c” in the background, in small slices that let the browser handle the next key. If another key arrives, React drops this render and starts again with the newer value.
The example: a search over a slow grid
The grid shows up to 120 cards, and each card takes about 1 ms to render (a stand-in for a genuinely expensive component). The recordings type “c”, “e”, “r” with 150 ms between keys, and log how long after each keystroke the browser could paint its next frame.
function ProductGrid({ query }: { query: string }) {
const results = catalog.filter((p) => p.name.toLowerCase().includes(query)).slice(0, 120)
log(` ProductGrid rendered for "${query}"`)
return (
<ul className="grid">
{results.map((p) => (
<SlowCard key={p.id} product={p} />
))}
</ul>
)
}
function SlowCard({ product }: { product: Product }) {
burn(1) // pretend this card is expensive to render
return <li>{product.name}</li>
}
1. Plain
function SearchPlain() {
const [query, setQuery] = useState('')
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductGrid query={query} />
</>
)
}
keystroke "c": next frame after 132 ms
keystroke "e": next frame after 129 ms
keystroke "r": next frame after 127 ms
Every keystroke waits for the whole grid: the input lags by over 100 ms per letter.
2. useDeferredValue without memo
function SearchDeferredNoMemo() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductGrid query={deferredQuery} />
</>
)
}
keystroke "c": next frame after 129 ms
keystroke "e": next frame after 124 ms
keystroke "r": next frame after 124 ms
No better. The renders explain why:
ProductGrid rendered for ""
ProductGrid rendered for "c"
ProductGrid rendered for "c"
ProductGrid rendered for "ce"
ProductGrid rendered for "ce"
ProductGrid rendered for "cer"
The urgent render passed the grid the old value, but ProductGrid isn’t memoized, so it rendered anyway, all 120 slow cards, for a query it had already shown. Then the background render did it again with the new value.
3. useDeferredValue with memo
function SearchDeferred() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const stale = query !== deferredQuery
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: stale ? 0.6 : 1 }}>
<MemoGrid query={deferredQuery} />
</div>
</>
)
}
keystroke "c": next frame after 7 ms
keystroke "e": next frame after 1 ms
keystroke "r": next frame after 14 ms
ProductGrid rendered for "c"
ProductGrid rendered for "ce"
ProductGrid rendered for "cer"
Now the urgent render gives MemoGrid the same query as before, memo skips it, and the input paints within about one frame. The grid renders once per value, in the background, and is dimmed while it’s stale.
How it works
- One update, two renders.
useDeferredValue(query)returns the old value in the urgent render and schedules a second, low-priority render with the new value. - The background render is interruptible. React works in small slices and yields to the browser between them (Scheduler, Lanes and Batching). If a new keystroke arrives, it abandons the stale render and starts over with the latest value. It only touches the DOM when a whole render is finished, so you never see half an update.
useTransitionvsuseDeferredValue: usestartTransition(() => setX(…))when you own the state update. UseuseDeferredValue(value)when the value comes from elsewhere (props, context, a URL).- Deferring vs debouncing: a debounce waits a fixed time, on every device, and the render still blocks when it finally runs. A deferred render starts immediately, adapts to the device, and can be interrupted. Debounce network requests; defer rendering.
- It’s not “faster”. The first render isn’t deferred, and the total work is the same. If you can make the component itself faster, do that first.
Common mistakes
- Forgetting
memoon the slow component (recorded above). - Passing the deferred component new objects or functions as props: the urgent render then fails
memoand renders anyway (Element Optimization). - Using it to limit API calls. It reduces rendering, not requests.
Interview Q&A
What is concurrent rendering?
React’s ability to render in interruptible pieces, yielding to the browser between them, and to abandon a render when newer input arrives. The DOM changes only when a whole render is done.
Urgent vs non-urgent updates?
Urgent updates reflect direct input (typing, clicking) and must feel instant. Non-urgent ones change what’s shown (filtered results, a new tab) and can lag a bit. You mark non-urgent ones with useTransition or useDeferredValue.
Why must the slow component be memoized for useDeferredValue to help?
The urgent render still renders the component that calls useDeferredValue, and its children with it. Only memo, seeing the same old prop, skips the slow child. Recorded: without memo, each keystroke still took over 100 ms and the grid rendered twice per keystroke; with it, about one frame or less.
useTransition or useDeferredValue?
useTransition when you call the state setter; useDeferredValue when you only receive the value.
Deferring vs debouncing?
Debouncing delays the work by a fixed time and still blocks when it runs. Deferring starts right after the urgent render, runs in interruptible chunks, and adapts to the device’s speed.
Related
- Element Optimization: why
memocan skip the urgent render. - Scheduler, Lanes and Batching: priorities and yielding inside React.
- Code Splitting: transitions that avoid Suspense fallbacks.
Sources
- react.dev:
useDeferredValue,useTransition, React 18: concurrent rendering - Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useDeferredValue