Skip to main content

useDeferredValue: A Lagging Copy

What you'll learn

  • What useDeferredValue returns, and why "lagging" is the right mental image
  • The two renders hidden inside the hook
  • The canonical use case: keeping a slow list off the input's critical path
  • useDeferredValue vs startTransition vs debounce, when each one applies
  • The staleness UX pattern, and the number-one misuse

Last chapter you marked an update as non-urgent with startTransition. But sometimes you don't control the update, the value arrives from props, a parent, a router, a library. There's no setState call site to wrap. useDeferredValue flips the API around: instead of deferring the setter, you defer the value where you consume it.

What the hook returns

Jargon: "deferred value". A copy of a value that intentionally lags behind the real one. React keeps rendering with the old copy until a background render with the new copy is ready to commit.

const deferredText = useDeferredValue(text);

The contract, precisely:

  1. When text changes, React first renders urgently with the old deferredText, the hook still returns the previous value.
  2. Then React starts a background transition render where the hook returns the new value.
  3. Until that background render commits, deferredText keeps returning the old value.
  4. If text changes again mid-render, the background draft is discarded and restarted with the newest value.
  5. When the background render finally commits, deferredText === text again, the lag has caught up.

It's last chapter's two-render mechanism, packaged as a hook: you get the urgent render automatically, and the transition render carries the new value.

The canonical example: search input + slow list

import { useState, useDeferredValue, memo } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {}
}

const SlowResults = memo(function SlowResults({ query }) {
  const items = [];
  for (let i = 0; i < 200; i++) {
    burnCpu(1); // ~200ms: pretend each result row is expensive
    items.push(
      <li key={i}>
        Result {i} for “{query || '…'}”
      </li>
    );
  }
  return <ul>{items}</ul>;
});

export default function SearchPage() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);
  const isStale = text !== deferredText;

  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Search…"
      />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SlowResults query={deferredText} />
      </div>
    </div>
  );
}

What happens:

  1. You type r. setText('r') is urgent, it's your keystroke echo.
  2. Urgent render: the input shows r instantly. useDeferredValue still returns the old '', so SlowResults gets the same query prop as before, and being memo'd, it's skipped entirely. This render is tiny; it commits within a frame.
  3. Background render starts with query = 'r': ~200ms of rows, time-sliced and interruptible.
  4. You type e while it's running. The r draft is discarded; a new background render starts with query = 're'.
  5. You pause. The newest background render finishes and commits: the list shows results for re, isStale flips to false, and the dimming clears.

Every keystroke costs one tiny urgent render. The expensive list updates at whatever rate the machine can afford, and only ever renders the latest query, never every intermediate one.

Two details worth burning in:

  • memo is load-bearing. Without it, every urgent render would re-run SlowResults with the old query, 200ms back on the critical path, and the hook buys you nothing. Deferred value + memoized consumer is the pair.
  • The input uses text, never deferredText. Hold that thought; it's the misuse section.

vs startTransition: same engine, different steering wheel

Under the hood there is one mechanism, transition-lane renders with time slicing and discarding. The two APIs differ only in where you point it:

  • You call the setState yourself → wrap the call site: startTransition(() => setTab(x)).
  • The value comes from outside, props from a parent, state owned by a library, a URL param from the router → there's no call site to wrap, so defer the value where you consume it: const deferred = useDeferredValue(value).

Rule of thumb: transitions defer updates you own; useDeferredValue defers values you're given.

vs debouncing: waiting vs adapting

Jargon: "debounce". A classic hand-rolled technique: don't act on a stream of events until the stream goes quiet for N milliseconds. Typing pauses for 300ms → now do the search.

Debouncing works, but look at what it actually does:

  • It waits for silence. While the user types, the list never updates, then updates all at once, arbitrarily late.
  • The delay is fixed and blind. 300ms is wasted time on a fast machine, and on a slow machine the heavy render still freezes the page when it finally fires.

A deferred value behaves differently on both axes:

  • It keeps updating during typing, at whatever rate the device can afford. Each background render carries the newest value, and drafts for stale intermediate values are thrown away.
  • It's adaptive. On a fast laptop the background render finishes nearly instantly, so deferredText barely lags. On a cheap phone it lags more, but the input never freezes, at any hardware tier.

Debounce says "guess how long the user pauses". Deferred value says "update as fast as this machine can, without blocking anyone".

The staleness UX pattern

A lagging list is honest only if the user can tell it's catching up. The hook hands you the signal for free:

const isStale = text !== deferredText;

While a background render is pending, the real value and the deferred value differ, so dim the stale content, grey it, show a subtle spinner. When the commit lands, they match again and the UI clears. One comparison gives you a designed, intentional "this is updating" state instead of a mysterious lag.

The number-one misuse

Deferring the input's own value:

import { useState, useDeferredValue } from 'react';

export default function BadSearch() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);

  return (
    <input
      value={deferredText} // DON'T: the input itself now lags behind your fingers
      onChange={(e) => setText(e.target.value)}
    />
  );
}

What happens: typing feels drunk, letters appear a beat after you press them. You deferred the one thing that must never lag: the direct feedback of the interaction (last chapter's urgent pile). The input is the urgent UI; defer the expensive consumer of the value, the list, the chart, the preview, never the source.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: deferredText updates on a timer. Reality: no timers, it updates when the background render commits, which depends on how busy the device is.
  • Misconception: useDeferredValue replaces memo. Reality: they're partners. The hook schedules the background render; memo keeps the urgent render cheap. You usually need both.
  • Misconception: It's just debounce with React branding. Reality: debounce waits for a fixed silence and then blocks; deferred value keeps streaming updates at the device's own pace, adaptively.
  • Misconception: Stale-looking UI is a bug. Reality: it's the designed trade, and text !== deferredText hands you the exact signal to present it honestly.
  • Misconception: More deferring is always better. Reality: if the consumer is cheap, deferring adds renders for nothing. Use it where the consumer is provably expensive.

Why it works this way

  • Value-based API = works where setters don't. When the update is owned by a parent, a router, or a library, the consumption point is the only place you control.
  • One mechanism, two doors. useDeferredValue reuses the transition machinery, nothing new under the hood, nothing new to debug.
  • Adaptive beats fixed. A scheduler that responds to the actual machine beats any hand-picked delay, on both fast and slow hardware.
  • Staleness is exposed, not hidden. text !== deferredText turns an implementation detail into a design tool.

Try it yourself

  1. Run SearchPage and type quickly. Expected: input instant, list dims and catches up when you pause. Now pass text directly to SlowResults instead of deferredText. Expected: typing freezes on every keystroke.
  2. Keep the hook but remove memo from SlowResults. Expected: the freeze returns, the urgent render re-runs the whole list with the old query. Lesson: hook + memo, together.
  3. Log text and deferredText on every render while typing. Expected: deferredText visibly trails during fast typing and snaps equal when you stop.
  4. Change the list to 20 rows, then to 2,000. Expected: with 20, the lag is barely visible; with 2,000, the list lags a lot, but the input never freezes. That contrast is adaptivity.

Recap

  • useDeferredValue(value) returns a lagging copy: old value during the urgent render, new value once the background render commits.
  • The mechanism is last chapter's two renders: urgent render with the old value, transition render with the new one.
  • Canonical use: instant input + slow memoized list. The hook and memo are a package deal.
  • Use startTransition when you own the setState; use useDeferredValue when the value comes from outside.
  • Unlike debounce, deferred values keep updating during typing and adapt to the device's speed.
  • Never defer the input's own value, defer the expensive consumer, and dim stale content with text !== deferredText.

Next

External stores and tearing →