Skip to content
React rc performance 4 min read

useTransition & useDeferredValue

When a single keystroke triggers an expensive re-render — filtering thousands of rows, re-laying out a chart, or re-tokenizing a code block — the input itself starts to lag because React blocks the main thread until the whole tree finishes. React’s concurrent features let you split updates into two priorities: urgent ones (what the user typed) and non-urgent ones (the heavy results). useTransition and useDeferredValue mark the heavy work as interruptible so the cursor stays smooth no matter how big the list gets.

The problem: one update, two priorities

Consider a search box that filters a large list. Without transitions, every change to the input state and the filtered list happens in the same render pass. If filtering 20,000 items takes 80ms, the browser cannot paint the new character until that work completes, so typing feels heavy and janky.

import { useState } from 'react';

function SlowSearch({ items }) {
  const [query, setQuery] = useState('');

  // This filter runs synchronously on every keystroke — blocking.
  const results = items.filter((item) =>
    item.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </>
  );
}

The fix is to tell React that the input update is urgent but the list update can wait — and can be thrown away if the user keeps typing.

Using useTransition

useTransition returns a boolean isPending flag and a startTransition function. State updates wrapped in startTransition are flagged as non-urgent (a “transition”). React renders them in the background and will abandon an in-progress transition render if a more urgent update (the next keystroke) arrives.

import { useState, useTransition } from 'react';

function Search({ items }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const next = e.target.value;
    setQuery(next); // urgent: keeps the input responsive

    startTransition(() => {
      // non-urgent: can be interrupted by the next keystroke
      setResults(
        items.filter((item) =>
          item.toLowerCase().includes(next.toLowerCase())
        )
      );
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span className="hint">Updating…</span>}
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </>
  );
}

The query state updates immediately, so the typed character paints right away. The results update happens at lower priority, and isPending lets you show subtle feedback (a spinner or dimmed list) while the deferred render is in flight.

Wrap only state updates in startTransition. The function must be synchronous — do not await inside it. For async data, fetch the data first, then call startTransition with the state update that uses the result.

Using useDeferredValue

useDeferredValue solves the same problem from the other direction. Instead of wrapping the setter, you keep one piece of state and derive a “lagging” copy of it. React keeps the deferred value at its previous value during urgent renders, then re-renders with the new value at lower priority.

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

function Search({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const results = useMemo(
    () =>
      items.filter((item) =>
        item.toLowerCase().includes(deferredQuery.toLowerCase())
      ),
    [items, deferredQuery]
  );

  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul style={{ opacity: isStale ? 0.6 : 1 }}>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </>
  );
}

Because the expensive filtering reads deferredQuery, it is the part React can defer. Wrapping it in useMemo ensures the filter only recomputes when the deferred value actually changes, not on every render. The isStale comparison plays the same role as isPending.

Before and after feel

AspectWithout transitionsWith useTransition / useDeferredValue
Input latencyBlocked until filter finishesCharacter paints immediately
Large list re-renderJanky, dropped framesRendered at low priority, interruptible
Stale resultsAlways synchronousPrevious results stay visible until ready
FeedbackNoneisPending / isStale flag

You can verify the difference in the React Profiler — without a transition you see long, uninterruptible commits on each keystroke; with one, the urgent input commit is tiny and the heavy commit is deferred.

Output:

# Typing "react" quickly into a 20,000-item filter

Without transition:
  keypress → 82ms blocking render → paint  (input feels sticky)

With transition:
  keypress → 3ms input paint
            ↳ background filter render (interruptible)
            ↳ commit results when idle

Choosing between the two

Use caseReach for
You own the setState call that triggers heavy workuseTransition
You want a loading indicator tied to the updateuseTransition (isPending)
The expensive value comes from props or context you don’t controluseDeferredValue
You want to defer derived/memoized output of one valueuseDeferredValue

Best Practices

  • Keep the urgent update (input value) outside the transition so typing never blocks.
  • Pair useDeferredValue with useMemo so the deferred branch is the only part that recomputes.
  • Use isPending / isStale for subtle feedback (dimming, a small hint) rather than a full spinner that causes layout shift.
  • Never put await or side effects inside startTransition — it expects a synchronous state update.
  • Transitions reduce perceived latency but do not make the work cheaper; combine them with virtualization for very large lists.
  • Measure with the React Profiler before and after to confirm commits became shorter and interruptible.
Last updated June 14, 2026
Was this helpful?