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 notawaitinside it. For async data, fetch the data first, then callstartTransitionwith 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
| Aspect | Without transitions | With useTransition / useDeferredValue |
|---|---|---|
| Input latency | Blocked until filter finishes | Character paints immediately |
| Large list re-render | Janky, dropped frames | Rendered at low priority, interruptible |
| Stale results | Always synchronous | Previous results stay visible until ready |
| Feedback | None | isPending / 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 case | Reach for |
|---|---|
You own the setState call that triggers heavy work | useTransition |
| You want a loading indicator tied to the update | useTransition (isPending) |
| The expensive value comes from props or context you don’t control | useDeferredValue |
| You want to defer derived/memoized output of one value | useDeferredValue |
Best Practices
- Keep the urgent update (input value) outside the transition so typing never blocks.
- Pair
useDeferredValuewithuseMemoso the deferred branch is the only part that recomputes. - Use
isPending/isStalefor subtle feedback (dimming, a small hint) rather than a full spinner that causes layout shift. - Never put
awaitor side effects insidestartTransition— 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.