useDeferredValue: Lagging Values for Smooth UIs
useDeferredValue solves a different problem than useTransition, even though both lean on the
same scheduler idea: some updates may trail the latest state so urgent work stays smooth. Where
useTransition wraps the function that performs updates, useDeferredValue operates on a
value—often a string, an id, or a prop coming from above. That distinction drives when each hook
leads to clearer TypeScript and simpler component boundaries.
How deferred values behave
Given a fast-changing value v, useDeferredValue(v) returns a lagging version that React
updates with lower priority. During bursts of change, React may keep showing UI derived from the
previous deferred value while it prepares a newer one. The result is a deliberate
stale-while-rendering experience: the input feels instant; the expensive projection catches up.
function Search({ bigList }: { bigList: string[] }) {
const [query, setQuery] = useState("")
const deferredQuery = useDeferredValue(query)
const results = useMemo(
() =>
bigList.filter((item) =>
item.toLowerCase().includes(deferredQuery.toLowerCase())
),
[bigList, deferredQuery]
)
const isStale = query !== deferredQuery
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
</>
)
}
useMemo ties the expensive filter to deferredQuery, not the live query. While query races
ahead, deferredQuery follows when the scheduler allows, so the list does not recompute on every
keystroke at urgent priority. The isStale flag is the user-visible honesty layer: subtle opacity
or a small “catching up” affordance signals that the heavy view may momentarily reflect an older
query.
This pattern shines when the costly work is pure derivation from a value you already have. Keep
the derivation in useMemo or push it down to a memoized child that receives deferredQuery so
React can bail out when props are referentially stable.
useTransition versus useDeferredValue
Both hooks interact with concurrent scheduling, but they optimize different integration points.
| useTransition | useDeferredValue | |
|---|---|---|
| What you control | The update function | The value |
| How to use | Wrap setState (and related updates) in startTransition |
Pass the live value into the hook |
| Cancellation semantics | In-flight transition renders can be discarded when superseded | The hook delays propagation; it does not cancel your derivation logic by itself |
| Typical ownership | You own the state setter in this component | You receive a prop or upstream value you cannot wrap |
| Batching story | One transition can cover multiple updates | Each deferred value tracks its own lag |
When you control the setState, useTransition is often the more explicit choice: you mark exactly
which updates are non-urgent. When a parent passes query or searchParams and you cannot ask
every caller to wrap transitions, useDeferredValue localizes the decision: this subtree will
intentionally lag.
Neither hook replaces algorithmic efficiency. If filtering a million rows is slow, deferral only smooths input; it does not change asymptotic cost. Combine deferred values with pagination, virtualization, or server-side narrowing for real wins.
Markdown preview and live editors
Text areas that drive rich previews are a textbook use case. Keystrokes update source state immediately; preview rendering—parse markdown, sanitize HTML, highlight syntax—runs off a deferred copy.
function MarkdownPreview({ markdown }: { markdown: string }) {
const deferred = useDeferredValue(markdown)
const html = useMemo(() => renderMarkdownToHtml(deferred), [deferred])
const isStale = markdown !== deferred
return (
<article
aria-busy={isStale}
style={{ opacity: isStale ? 0.85 : 1 }}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
TypeScript note: if renderMarkdownToHtml returns a branded type or a sanitized wrapper, keep that
typing strict so dangerouslySetInnerHTML never receives raw user strings without passing through
your sanitizer.
Large tables and dashboards
In data-dense UIs, defer the props that drive heavy columns—sort keys, filters, or time ranges—while keeping tooltips and hover highlights on the urgent path in sibling components. Splitting the tree matters: a deferred value does not help if a single giant component still recomputes everything whenever any parent state changes.
A practical structure:
- Urgent subtree: controls, focused row highlight, inline editors.
- Deferred subtree: aggregated charts or wide tables fed by
useDeferredValue(filterState).
Measure with the React Profiler plus real hardware. Deferral improves responsiveness; virtualization improves scalability.
Search params and shareable URLs are another common source of “hot” values you do not own. When
the router parses ?q= or ?sort= into props, a leaf component can defer those props before
passing them into a memoized child. The address bar stays authoritative for the user and for
analytics; the heavy table simply lags one beat behind without forcing every route parent to know
about startTransition.
You can also push deferral to the child boundary by passing both the live and deferred values explicitly when you need fine control:
function ResultsPane({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query)
return <HeavyResults deferredQuery={deferredQuery} liveQuery={query} />
}
HeavyResults can dim itself when liveQuery !== deferredQuery, while still receiving a
deferredQuery prop that moves more slowly during bursts of typing—useful when the same component
is reused in stories or tests where explicit prop names make expectations clear.
Mental model for code review
Ask whether the lagging UI is safe to show stale. Financial totals that must always match the selected filters should not drift visually without strong disclaimers. Search results and previews usually can. For borderline cases, combine a deferred value with explicit copy: “Results may update momentarily.”
Also remember: isPending from useTransition and the query !== deferredQuery stale flag are
related ideas but not identical. Pending spans transitions you wrapped; staleness compares live
versus deferred values. Some teams use both in the same screen—transition around a server
mutation, deferred value around a local heavy render—so naming variables (isSavePending vs
isListStale) keeps reviews readable.
useDeferredValue is the right default when the expensive work is downstream of a value you read,
not a callback you write. For mutating flows that need instant feedback and server confirmation,
the next section’s useOptimistic layers optimistic state on top of the same scheduling story.