open atlas
↑ Back to track
React patterns, senior RXP · 11 · 03

Transitions and deferred values

useTransition marks a state update non-urgent so typing stays responsive; useDeferredValue lags a derived value behind input — both keep the UI snappy under heavy renders and let Suspense show stale content instead of a fallback.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You build a search box over a 10,000-row list. Each keystroke sets the query, the list re-filters, and the new rows render. On your machine it’s fine. On the user’s mid-range laptop, typing “engineer” feels like typing through mud — the input lags a character or two behind their fingers, because every keystroke blocks the main thread re-rendering the whole list before the next character can appear.

Nothing is broken. The filter is correct, the list is right. The problem is that React is treating two updates with very different urgency — the input value (must feel instant) and the filtered list (can arrive a beat later) — as equally urgent. Transitions are how you tell React the difference, so the urgent update wins the main thread and the expensive one yields.

Goal

After this lesson you can mark an expensive state update as non-urgent with useTransition so urgent updates (typing) stay responsive; use useDeferredValue to lag a derived value behind its input when you don’t own the setter; explain how transitions let Suspense show the previous content instead of a fallback while the next loads; and name the failure mode — wrapping the urgent update itself (the input value) in a transition, or reaching for either before you’ve measured an actual responsiveness problem.

1

useTransition splits one event into two updates: an urgent one that renders immediately, and a non-urgent one React can interrupt. You call setQuery urgently so the input reflects the keystroke at once, then call the expensive setResults inside startTransition. React renders the urgent update first; the transition render happens in the background and can be thrown away if another keystroke arrives, so it never blocks input.

const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState<Row[]>(allRows);

function onChange(e: React.ChangeEvent<HTMLInputElement>) {
  const next = e.target.value;
  setQuery(next); // urgent: the input must update now
  startTransition(() => {
    setResults(filterRows(allRows, next)); // non-urgent: can lag, can be dropped
  });
}

isPending is true while a transition render is in flight — use it to dim the stale list, never to block typing.

2

useDeferredValue is the same idea when you don’t own the setter — you defer a value, not an update. A child receives query as a prop and does the expensive work itself; you can’t wrap that child’s internal render in startTransition. Instead, hand it a deferred copy of the value. React keeps the input bound to the live query, but lets the deferred copy lag during heavy renders.

const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;

return (
  <>
    <input value={query} onChange={(e) => setQuery(e.target.value)} />
    {/* SlowList re-renders against deferredQuery, lagging behind input */}
    <SlowList query={deferredQuery} dim={isStale} />
  </>
);

Rule of thumb: own the setter at the call site → useTransition. Receive the value as a prop you can’t intercept → useDeferredValue. Memoize the expensive child (memo) so the deferred lag actually saves renders.

3

Under Suspense, a transition tells React to keep showing the previous content instead of dropping to a fallback. When the deferred update reads data that suspends, a normal update would unmount the old UI and show the <Suspense> fallback — the list blinks to a skeleton on every keystroke. Wrapping the update in a transition (or letting useDeferredValue drive it) makes React hold the last good content on screen and swap in the new results only when they’re ready. You get stale-while-loading for free.

function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Suspense fallback={<ResultsSkeleton />}>
        {/* first paint shows the skeleton; later keystrokes keep the
            previous results visible instead of re-blinking to skeleton */}
        <Results query={deferredQuery} />
      </Suspense>
    </>
  );
}

The skeleton shows once, on the first load. After that, transitions turn every subsequent fetch into a soft content swap, not a flash.

4

The failure mode that defines the pattern: never wrap the urgent update in a transition, and never reach for either API before measuring. If you put setQuery inside startTransition, the input value itself becomes interruptible — typing lags, the exact symptom you were trying to cure. The urgent update (what the user is directly manipulating) must stay outside. The second failure is premature: a list of 50 rows re-renders in under a millisecond; adding useTransition/useDeferredValue there buys nothing and adds a stale-state concept to reason about. These APIs pay off only when an expensive render is measurably starving an urgent one.

// WRONG: the input value is urgent — this makes typing laggy
startTransition(() => {
  setQuery(next);          // urgent update demoted to non-urgent
  setResults(filterRows(allRows, next));
});

// RIGHT: urgent stays urgent, only the expensive part is deferred
setQuery(next);
startTransition(() => setResults(filterRows(allRows, next)));

Reach for transitions when you’ve seen input lag under heavy updates — not as a default wrapper around every setState.

Worked example

A search box over a large list — laggy version, then fixed. Start with the naive version where one update does everything:

function ProductSearch({ all }: { all: Product[] }) {
  const [query, setQuery] = useState("");
  // every keystroke filters 10k rows synchronously, blocking the input
  const visible = useMemo(() => filterProducts(all, query), [all, query]);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ProductList items={visible} />
    </>
  );
}

The filter is memoized, but it still runs on the urgent path: each keystroke must finish the filter before the input repaints, so typing stalls on the user’s machine. Now split the urgencies. Keep the input bound to the live query, but render the list against a deferred copy and memoize the list so the lag actually skips work:

const ProductList = memo(function ProductList({ items }: { items: Product[] }) {
  return <ul>{items.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
});

function ProductSearch({ all }: { all: Product[] }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  // expensive filter now keys off the DEFERRED value, off the urgent path
  const visible = useMemo(
    () => filterProducts(all, deferredQuery),
    [all, deferredQuery],
  );
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <ProductList items={visible} />
      </div>
    </>
  );
}

Now the input repaints on every keystroke against query, while the heavy filter and list render lag behind on deferredQuery. The isStale flag dims the list so the user sees it’s catching up — feedback without blocking. If the list also fetched and suspended, wrapping it in <Suspense> would keep the old rows visible during the fetch instead of blinking to a skeleton. The input stays snappy because the urgent update never shares a render with the expensive one.

Why this works

Why does deferring help at all if the same total work still runs? Because it changes when and whether it runs relative to input. The urgent render (input) commits first and immediately, so typing is never blocked. The deferred render runs after, in the background, and React can abandon an in-flight deferred render the instant a newer keystroke arrives — so during fast typing the intermediate filters are dropped entirely, not just reordered. You trade a slightly stale list for an always-responsive input, which is exactly the right trade for a search box.

Common mistake

A subtle trap: useDeferredValue and useTransition only buy responsiveness if the expensive child actually skips re-rendering when the deferred value is unchanged. If ProductList isn’t wrapped in memo (or its props change identity every render), it re-renders on the urgent pass anyway and the deferral saves nothing — you’ve added a stale flag for no win. Always pair the deferred value with a memoized consumer, and confirm with the React DevTools Profiler that the expensive subtree is skipping the urgent render. No measured skip, no benefit.

Check yourself
Quiz

A search box over a huge list types laggily. A teammate fixes it by wrapping BOTH setQuery (the input value) and setResults (the filtered list) inside one startTransition. The list updates smoothly now — but typing still feels laggy. Why, and what's the correct split?

Recap

useTransition marks a state update as non-urgent: you call the urgent setter (the input value) immediately and outside it, then wrap the expensive setter inside startTransition so React renders it in the background, can interrupt it, and drops stale ones during fast input. useDeferredValue is the same trade when you only have a value (a prop you don’t own the setter for): hand the expensive child a deferred copy that lags behind the live value, and memoize that child so the lag actually skips work. Under Suspense, both let React keep the previous content on screen while the next loads, turning a fallback flash into a soft, stale-while-loading swap. The defining failure mode is putting the urgent update — the value the user directly manipulates — inside a transition, which makes typing laggy; the second is reaching for either API before you’ve measured an expensive render starving an urgent one. Match the API to the pressure: own the setter → transition; receive the value → deferred; and only when responsiveness is a real, observed problem.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.