Ch. 3 · React

React Deferred Results and Stale UI

Keep a search input responsive with useDeferredValue, show users when results are stale, and see why memo is required and fetches aren't reduced.

~6 min readintermediateupdated Oct 19, 2026

Picture a page listing 2,000 interview questions with a filter box above them. Each keystroke filters the list and re-renders a few hundred rows with syntax-highlighted snippets, which takes 80 ms on a mid-range laptop and far longer on a cheap phone. Typing “closure” becomes seven janky 80 ms freezes, and the letters show up in bursts.

The problem isn’t that filtering is slow. It’s that the slow work and the fast work (showing the character the user just typed) are tied to the same state update, so the fast work waits for the slow work. useDeferredValue separates them. You end up with two timelines: what the user is typing right now, and what the results currently reflect. A good interface keeps the first instant and makes any gap between them visible.

Two values from one state

function QuestionSearch({ questions }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter questions" />
      <div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms 100ms' }} aria-busy={isStale}>
        <QuestionList questions={questions} query={deferredQuery} />
      </div>
    </>
  );
}

const QuestionList = memo(function QuestionList({ questions, query }) {
  const visible = questions.filter((q) => q.title.toLowerCase().includes(query.toLowerCase()));
  return <ul>{visible.map((q) => <QuestionRow key={q.id} question={q} />)}</ul>;
});
JSX

When the user types, React does two renders. The first is urgent: query has the new text, but deferredQuery still holds the previous value. The second runs in the background with deferredQuery caught up. If another keystroke arrives while that background render is in progress, React abandons it and starts again with the newest value. It never shows a half-finished background render.

You can watch the sequence. This is the log from rendering a small version of the component above with React 19 and typing “m”, then “mu”, then “mug”:

Search render: query="" deferred="" stale=false
  SlowResults("")
--- type "m"
Search render: query="m" deferred="" stale=true
Search render: query="m" deferred="m" stale=false
  SlowResults("m")
--- type "mu" then "mug" in one tick
Search render: query="mu" deferred="m" stale=true
Search render: query="mu" deferred="mu" stale=false
  SlowResults("mu")
Search render: query="mug" deferred="mu" stale=true
Search render: query="mug" deferred="mug" stale=false
  SlowResults("mug")
Text

Look at the urgent renders, the ones with stale=true. The memoised results component (called SlowResults in that test) doesn’t appear under them. The parent re-rendered with the new input value, React saw that the child’s query prop was the same deferred string as before, and skipped it. The expensive work only happens in the background renders.

In that test each keystroke was processed separately, because act flushes everything synchronously. In a real browser with a genuinely slow list, the “mu” background render would often be interrupted by “mug” and thrown away, so the list would jump straight from “m” to “mug”.

Why memo isn’t optional

Remove memo from QuestionList and the deferral stops working. The urgent render re-renders the parent, the parent re-renders all its children by default, and QuestionList filters and renders 2,000 rows inside the urgent update even though its query prop hasn’t changed. The input lags exactly as before, and now there’s a second render on top.

So the pattern has three parts that must all be present: the deferred value, a child that receives only the deferred value, and memo on that child. If the child also receives a prop that changes on every render, such as an inline onSelect={() => ...} or a freshly built filters object, memo sees a changed prop and renders it in the urgent pass anyway. Pass stable references, or let the React Compiler memoise them if your project uses it.

Telling users what they’re looking at

For a moment the input says “mug” and the list shows results for “mu”. That’s fine, if it’s visible. Interfaces get this wrong in two directions.

Hiding staleness entirely makes the app feel wrong: the user types a query that should return nothing, sees 40 results for a moment, and wonders whether the filter works. Over-signalling is just as bad: flashing a spinner for 30 ms on every keystroke is noisier than the lag you removed.

A few practical choices:

  • Dim, don’t replace. Lower opacity on the results while isStale is true, with a short transition delay (the 100ms in the example) so fast updates never flicker. The old results stay readable.
  • Label the mismatch when it lasts. A caption like “Showing results for “mu”” next to the count tells users exactly what they’re seeing. Render it from deferredQuery, so it’s always truthful.
  • Expose it to assistive tech carefully. aria-busy on the results region signals an update in progress. Announce the new result count once it settles, not on every intermediate state.
  • Don’t block input. Never disable the input while results are stale; the whole point is that typing stays live.

What it doesn’t do

It doesn’t reduce network requests. useDeferredValue schedules rendering. If an effect fetches results whenever deferredQuery changes, it still fires once per value that actually commits. Fast typing may skip some intermediate values because interrupted renders never commit, but you can’t rely on that as a debounce. If your server shouldn’t see every keystroke, debounce the request explicitly and cancel stale ones with an AbortController, or let your data library do it.

There’s no fixed delay. Unlike a 300 ms debounce, the background render starts immediately. On a fast device the lag is barely visible; on a slow one it’s longer. That adaptivity is the point, but it means you can’t use it to wait for the user to stop typing.

It doesn’t make the list faster. It moves the cost off the critical path. If each background render takes 900 ms, the results will feel sluggish however responsive the input is. Virtualise long lists, or move filtering into a precomputed index.

With Suspense-based data (a framework loader or the use hook with a cached promise), deferring the query has one extra benefit: when the new results suspend, React keeps showing the old ones instead of the nearest <Suspense> fallback, because the suspended render is a background one. And React 19 added an optional second argument, useDeferredValue(query, ''), which renders the initial value first on mount and then the real value in the background.

Practice question: deferral that didn’t help

“A teammate added useDeferredValue to a product filter page. React DevTools shows two renders per keystroke now instead of one, and typing feels just as slow. What would you check?”

Answer. Two renders per keystroke is expected; the question is what happens in the first one. I’d record a Profiler session and see whether the expensive product grid renders during the urgent render. If it does, the deferral isn’t isolated. The usual causes are that the grid isn’t wrapped in memo, or that it receives props that change on every render, such as an inline callback, a new filters object, or the non-deferred query used for highlighting. I’d make sure the grid receives only deferredQuery plus stable references, wrap it in memo, and confirm in the Profiler that urgent renders now touch only the input. If background renders are still very slow, deferral has done its job and the remaining fix is making the grid cheaper, for example by virtualising it. I’d also check whether a fetch effect is keyed on the raw query, which would keep the network busy regardless.

Keep learning

More in React

read ✓React · mid

React Transitions and Urgent Input Updates

useTransition explained: keep typing responsive by marking heavy renders as non-urgent, why input state stays urgent, and useDeferredValue.

~6 min readread →
read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
read ✓React · mid

React lazy Loading and Code Splitting

Split React bundles with lazy and Suspense, preload chunks before the click, and handle the chunk-load failures that follow every deploy.

~6 min readread →
esc