useDeferredValue and useTransition both exist to solve the same underlying problem: preventing expensive renders from making the UI feel frozen. The distinction between them comes down to where you apply the deferral. useTransition wraps a state update in a lower-priority transition. useDeferredValue wraps a value that depends on state, deferring the expensive derived render that depends on it. One is for controlling updates you trigger. The other is for controlling renders triggered by values you receive.
That difference matters when you build a typeahead search, a filterable table, or any interface where a fast interaction feeds into a slow computation. This comparison walks through a single test case — a search-as-you-type filter over a dataset of ten thousand records — and measures both approaches side by side, including where each one breaks down.
The Test Setup
The component renders an input field and a list of filtered results. Every keystroke updates the query state, which then runs through a filter function over the full dataset. To make the render cost realistic, each result row includes several nested components and some basic text formatting — nothing pathological, but enough work that a synchronous render of all matching rows visibly stalls the input.
The baseline version has no concurrency features at all:
function SearchList({ allItems }) {
const [query, setQuery] = useState('');
const filtered = useMemo(
() => allItems.filter(item => item.name.toLowerCase().includes(query.toLowerCase())),
[allItems, query]
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{filtered.map(item => <ResultRow key={item.id} item={item} />)}
</ul>
</div>
);
}
With a dataset of ten thousand items and a query that matches roughly half of them, the filter pass takes about 60 milliseconds, and rendering the matched rows takes another 120 milliseconds. On a mid-range laptop, that’s roughly 180 milliseconds of blocked work per keystroke. The input visibly stutters, and characters arrive in bursts rather than smoothly registering.
The useTransition Variant
The first fix applies useTransition to wrap the query state update itself:
function SearchList({ allItems }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
return (
<div>
<input
value={query}
onChange={e => startTransition(() => setQuery(e.target.value))}
placeholder="Search..."
/>
{isPending && <p>Updating…</p>}
<ul>
{filteredItems.map(item => <ResultRow key={item.id} item={item} />)}
</ul>
</div>
);
}
In testing, the input response time improved from 180 milliseconds of blocked time to near-zero perceived latency. React schedules the filter and re-render as a lower-priority transition, letting the keystroke land and the input render immediately. The results catch up a frame or two later. That’s the ideal outcome for this shape of interaction.
The tradeoff appears when the user types quickly. The transition stays pending until the expensive render finishes, which means intermediate queries are skipped. If you type “produc” as a single burst, React may only render results for “pro” and then skip straight to “produc” — the intermediate states collapse. That’s acceptable for a search filter, but for a controlled input that displays the query back to the user, the input itself remains responsive, so the visual feedback is complete even if the result list lags briefly.
The larger caveat: useTransition only defers state updates that originate inside the wrapped callback. If the query state is updated from somewhere else — a parent component, a global store, a URL parameter — the transition wrapper does nothing. The deferral logic is tied to the update site, not to the value itself.
The useDeferredValue Variant
The alternative approach keeps the input update urgent and defers the derived value instead:
function SearchList({ allItems }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => allItems.filter(item => item.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[allItems, deferredQuery]
);
const isStale = deferredQuery !== query;
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{isStale && <p>Updating…</p>}
<ul>
{filtered.map(item => <ResultRow key={item.id} item={item} />)}
</ul>
</div>
);
}
The input update remains at default priority. The deferredQuery lags behind the actual query by one render cycle when the derived render is expensive. React re-renders the input immediately with the new query, then re-renders the list with the deferred value once the main thread frees up.
In the same test, typing behavior was nearly identical to the useTransition variant. The input stayed responsive, the result list updated a frame or two behind, and intermediate states collapsed under fast typing. Both approaches measured within a few milliseconds of each other on input latency.
The key difference appears when the query value originates outside the component. With useDeferredValue, the deferral follows the value wherever it comes from — a parent-controlled input, a Redux store, a URL search parameter passed in as a prop. The hook sits at the derivation site, not the update site, which makes it the better fit when the state lives elsewhere or when multiple consumers each need their own deferred version of the same value.
Where the Two Diverge in Practice
The test surfaced three situations where the choice changes how the code behaves.
Multiple expensive derivations from one state. If one query value feeds both a filtered list and a separate aggregated summary component, useTransition wraps the single update, and React defers all of the derived renders together. That’s fine. But if you only want to defer the list while keeping the summary urgent ,useDeferredValue gives you per-derivation control — wrap one value, leave the other reading the raw query directly.
State updates that aren’t the input itself. Suppose the search box lives in a child component, and the query state lives in a parent that also owns the dataset. The parent can call startTransition around the child’s update callback when it’s wired through props. But if the child updates a shared store directly, the parent can’t intercept the update with a transition. In that case, useDeferredValue on the parent side is the only way to control render priority without restructuring the state flow.
Progress feedback. The isPending flag from useTransition reflects whether any transition is in progress, including transitions started by other components in the tree. That can under-report or over-report what the current component is doing. useDeferredValue has no built-in pending flag, but comparing deferredQuery to query gives a precise signal — stale if they differ, current if they match. That comparison is exact per-value, not per-transition.
A Hybrid Approach Worth Considering
The test also tried combining both. The input update stayed urgent for responsiveness. The query value was deferred for the filter. And the filtered list wrapped its own render in a memoized derivation. This layered the two hooks rather than substituting one for the other.
The measured result was no better than using either hook alone. In cases where the only expensive work derives from the query value, one deferral is sufficient. Adding both layers just added reference checks and a second scheduling decision without moving the performance needle. In testing, the hybrid produced identical input latency and identical result-list timing within noise margins.
The scenario where combining them makes sense: the update itself triggers expensive work that isn’t purely derived from the value. For example, if setting the query also resets a paginated dataset or triggers a side effect that rebuilds a large structure, wrapping the update in a transition defers that non-derived work while useDeferredValue handles the render-side computation. That combination is rare but legitimate, and the profiling numbers confirmed it was the only case where layering paid for itself.
Profiling Results Summary
The measurements below came from the same dataset and component structure across all variants, collected with React DevTools Profiler on a throttled mid-range profile.
| Approach | Input Latency (per keystroke) | Result Update Delay | Intermediate States Skipped | Works when state is external | Extra API surface |
|---|---|---|---|---|---|
| Baseline (no hook) | ~180 ms blocked | Synchronous | None | N/A | None |
| useTransition | <10 ms perceived | ~1–2 frames | Yes | No | isPending flag |
| useDeferredValue | <10 ms perceived | ~1–2 frames | Yes | Yes | Value comparison for staleness |
| Hybrid (both) | <10 ms perceived | ~1–2 frames | Yes | Yes | Both hooks |
The practical rule from this testing: useDeferredValue covers more ground with less coupling to the update site, while useTransition is the right tool when you specifically need to defer an update callback that wraps multiple state changes or side effects. For the common case — an input feeding an expensive derived render — useDeferredValue is the one to reach for when the state may come from anywhere, and useTransition is the one to reach for when you control the event handler directly and want to wrap several updates together.
The Takeaway That Matters
Measuring both hooks side by side on the same workload showed the two are functionally equivalent for the typical deferral scenario. The differences are architectural, not performance-based. useTransition centralizes the deferral at the update site. useDeferredValue decentralizes it to the read site. For a codebase where state flows through props and stores, the read-site approach tends to fit more naturally and breaks down less often when state ownership shifts.
Start with the simplest version that keeps the input responsive, profile to confirm the expensive render is what’s blocking, and then choose the hook whose deferral point matches where the slow work happens. If the slow work is a direct consequence of a value, defer the value. If the slow work is a consequence of the update itself, defer the update. That distinction covers the majority of real interfaces, and this test confirmed it holds up under measurement.
🔗 Recommended Reading
- TanStack Query Caching Performance: Best Practices That Hold Up Under Load
- Zustand Selector Patterns: The Real Reason Your React Components Are Re-Rendering
- Optimizing WebSocket Real-Time Updates in React
- Building a PWA Caching Strategy for React Performance: The Service Worker That Cut Our Load Times
- Improving Largest Contentful Paint (LCP) in React Apps: A Beginner vs Advanced Guide