In one minute
A search box whose results come from the server has two jobs that pull against each other: the input must update on every keystroke, and the results must wait for the server. Putting setQuery in startTransition makes the results wait without a fallback, but it also makes the input’s own value wait, and React puts the old value back in the box: typed letters get lost. The fix is to keep the query urgent and defer only the results: const deferredQuery = useDeferredValue(query). The input renders immediately; the results render in the background with the new value, and if that suspends, React keeps showing the previous results (dim them with query !== deferredQuery).
You’ll be able to: keep a search input responsive over Suspense-driven results, and explain why a transition around the input misbehaves.
1function Results({ query }) {2 const products = use(search(query)) // 400 ms per query, cached3 return <p>{products.length} results for "{query}"</p>4}56function Search() {7 const [query, setQuery] = useState('')8 return (<>9 <input value={query}10 onChange={e => startTransition(() => setQuery(e.target.value))} />11 <Suspense fallback="Searching…"><Results query={query} /></Suspense>12 </>)13}- event
onChange={e => startTransition(() => setQuery(e.target.value))} />Type “l”. The input’s own value is this transition state, which waits for the results, so React puts the old value back in the box. - event
onChange={e => startTransition(() => setQuery(e.target.value))} />Type “a”. It lands in an empty box, so the query becomes “a”, not “la”. - commit phase
const products = use(search(query)) // 400 ms per query, cachedRecorded: the box ended up containing “a”, and the results were for “a”. The typed “l” was lost.
1function Results({ query }) {2 const products = use(search(query)) // 400 ms per query, cached3 return <p>{products.length} results for "{query}"</p>4}56function Search() {7 const [query, setQuery] = useState('')8 const deferredQuery = useDeferredValue(query)9 return (<>10 <input value={query} onChange={e => setQuery(e.target.value)} />11 <Suspense fallback="Searching…">12 <Results query={deferredQuery} />13 </Suspense>14 </>)15}- event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “l”. The urgent render updates the input right away; the results still get the old deferred value, so nothing suspends. - render phase
<Results query={deferredQuery} />The background render with the new value suspends, so React keeps the old results; dim them withquery !== deferredQuery. - event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “a”: typing continues normally. - commit phase
const products = use(search(query)) // 400 ms per query, cachedThe latest results replace the stale ones. The in-between search for “l” was never shown.
The example: product search
Each search takes the server 400 ms and is cached by query. The recordings type “l” and “a” 120 ms apart, and log what the input showed on the next frame after each key.
function Results({ query }: { query: string }) {
const products = use(search(query))
return <p data-details={`${products.length} result${products.length === 1 ? "" : "s"} for "${query}"`}>{products.map((p) => p.name).join(', ')}</p>
}
1. The query in a transition
function SearchTransition() {
const [query, setQuery] = useState('')
const [, startTransition] = useTransition()
return (
<>
<input id="search" value={query} onChange={(e) => startTransition(() => setQuery(e.target.value))} />
<Suspense fallback={<p data-fallback>Searching…</p>}>
<Results query={query} />
</Suspense>
</>
)
}
after "l" the input shows ""
500 ms → GET /search?q=l
after "a" the input shows ""
650 ms → GET /search?q=a
900 ms ← GET /search?q=l
1050 ms ← GET /search?q=a
screen: details: 5 results for "a"
The box stayed empty after both keys, and the second search went out for “a”, not “la”. The input’s value comes from query, which only changes when the transition finishes, and that waits for the results. Meanwhile React re-rendered the controlled input with the old value, wiping the “l”. The box ended up containing:
a
2. The results deferred
function SearchDeferred() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }} data-stale={isStale || undefined}>
<Suspense fallback={<p data-fallback>Searching…</p>}>
<Results query={deferredQuery} />
</Suspense>
</div>
</>
)
}
screen: details: 6 results for "" (dimmed)
after "l" the input shows "l"
550 ms → GET /search?q=l
after "a" the input shows "la"
650 ms → GET /search?q=la
950 ms ← GET /search?q=l
1050 ms ← GET /search?q=la
screen: details: 1 result for "la"
The input showed every letter immediately. The old results stayed (dimmed) while the new ones loaded, and the in-between search for “l” was never displayed: the background render for “l” was replaced by the newer one for “la”.
How it works
- One state change, two renders. With
useDeferredValue, a keystroke first renders with the newqueryand the olddeferredQuery(urgent: the input updates; the results get the same props as before, nothing suspends). Then React renders again in the background with the newdeferredQuery. - If the background render suspends, React keeps what’s on screen instead of showing the fallback, the same rule as transitions (Promise Caching and Transitions). The first render has no old value, so the initial load still shows the fallback.
- Stale or not?
query !== deferredQueryis true exactly while the results are behind; use it to dim them. useTransitionvsuseDeferredValue: useuseTransitionwhen you own an update that shouldn’t block (a tab switch). UseuseDeferredValuewhen one value has both an urgent use (the input) and a slow one (the results).
Common mistakes
- A transition around a controlled input’s state (recorded: lost keystrokes).
- Passing the urgent
queryto the results as well as the deferred one: then the urgent render suspends. - Expecting it to reduce requests. Every value that reaches the background render may start a request; debounce the requests if that matters.
Interview Q&A
What does useDeferredValue do?
It returns the previous value during urgent renders and schedules a background render with the new one. Parts that use the deferred value can lag behind without blocking the parts that use the current one.
Why not just wrap setQuery in startTransition?
The input’s own value is that state. In a transition it doesn’t update until the results are ready, and React resets the controlled input to the old value. Recorded: typing “la” left “a” in the box.
What does the user see while the deferred results load?
The previous results, because React keeps revealed content when a background render suspends. Recorded: “6 results for “”” dimmed until “la” arrived.
How do you know the results are stale?
query !== deferredQuery.
Is it the same as debouncing?
No. Debouncing waits a fixed time before doing anything. A deferred value starts the background render immediately and abandons it if newer input arrives, so on a fast device and network it’s instant.
Related
- Concurrent Rendering:
useDeferredValuefor slow rendering instead of slow data. - Promise Caching and Transitions: caching search promises, and transitions.
- Inputs: controlled inputs.
Sources
- react.dev:
useDeferredValue(showing stale content while fresh content is loading),useTransition - Topic order inspired by Kent C. Dodds’ EpicReact React Suspense workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useDeferredValue