Good to knowReact Suspense · 05

useDeferredValue with Suspense

A search box that stays responsive while results load; a transition around the input loses keystrokes (recorded).

On this page · 7 sections
  1. In one minute
  2. The example: product search
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

startTransition
Search.tsx
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}
What you see
6 results for ""
  1. eventonChange={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.
  2. eventonChange={e => startTransition(() => setQuery(e.target.value))} />Type “a”. It lands in an empty box, so the query becomes “a”, not “la”.
  3. commit phaseconst 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.

0 / 0
Typing “la” quickly into a search whose results come from the server, 400 ms per search (both versions recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/useDeferredValue