Concurrent Rendering — useTransition, useDeferredValue, How Priorities Work
React 18+ can interrupt rendering to keep the main thread responsive: high-priority updates (typing, clicking) preempt lower-priority ones (large list filter, route transitions). You opt into the deprioritization with useTransition (“this state update is non-urgent”) and useDeferredValue (“give me a lagged version of this value”). They’re INP-critical features — most app slowness comes from one heavy render blocking a fast interaction.
What does “concurrent rendering” actually mean?
React’s reconciler can start, pause, and resume rendering work. Before React 18, render was synchronous start-to-finish; if it took 200ms, the main thread was blocked for 200ms. Concurrent rendering chunks the work and yields back to the browser between chunks if a higher-priority event arrives.
Implications:
- An expensive
setStatedoesn’t lock the UI. - The user can type/click during long renders without lag.
- React picks up where it left off, or discards the in-progress render if it’s now stale.
Concurrency in React is opt-in via specific APIs (useTransition, useDeferredValue, <Suspense>) — it doesn’t suddenly make all renders interruptible by default.
useTransition — what’s the API and when do you use it?
import { useState, useTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// urgent — input stays snappy
setQuery(value);
startTransition(() => {
// non-urgent — can be interrupted
setResults(computeResults(value));
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <Spinner />}
<List items={results} />
</>
);
}What happens:
setQueryruns immediately; the input feels responsive.setResultsinsidestartTransitionis marked non-urgent.- If the user types again before
setResults’s render finishes, React discards the in-progress render and starts fresh with the latest value. isPendingis true while the transition’s render is in progress.
Use for: filters, search, route transitions, expensive recomputes triggered by user input.
useDeferredValue — what’s the difference?
useDeferredValue(value) returns a value that lags behind the source by a render or two — React renders the urgent change with the old value first, then re-renders with the new value when it has spare cycles.
function Search({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => computeResults(deferredQuery), [deferredQuery]);
const isStale = query !== deferredQuery;
return <List items={results} className={isStale ? "stale" : ""} />;
}The difference vs useTransition:
useTransitionwraps a state setter — you opt in at the write site.useDeferredValuewraps a value — you opt in at the read site.
Use useDeferredValue when you don’t control the state update (the value comes from props or a hook), or when you want to defer rendering based on a value without restructuring your setState calls.
When are they interchangeable?
Often. For a typeahead controlled by your own state:
// Option A — useTransition
const [isPending, start] = useTransition();
function onChange(v: string) { setQuery(v); start(() => setResults(compute(v))); }
// Option B — useDeferredValue
const [query, setQuery] = useState("");
const deferred = useDeferredValue(query);
const results = useMemo(() => compute(deferred), [deferred]);Both achieve the same effect — non-blocking heavy work, urgent input updates. useTransition gives you isPending; useDeferredValue gives you value !== deferredValue as the stale flag.
What does “priority” mean inside React’s scheduler?
React internally tags updates with a priority level. Roughly (simplified):
| Priority | Examples |
|---|---|
| Sync / Discrete | Click, keypress, form input updates |
| Continuous | Scroll, hover, drag |
| Default | setState outside event handlers |
| Transition | startTransition-wrapped updates |
| Idle | (internal) work that can wait |
Higher-priority updates interrupt lower-priority renders. The scheduler yields to the browser between work chunks (~5ms slices) and checks if anything urgent is pending.
You don’t set priorities directly — React infers from API. useTransition is the public knob to demote an update.
How does this connect to INP?
INP is the latency from user input → next paint. The bottleneck is usually:
- Event handler runs.
- React renders affected components. (← long if you don’t transition)
- Browser paints.
useTransition shrinks step 2 from the user’s POV: the urgent state (input value) renders fast; the expensive state (filtered list of 5000 items) renders deferred. INP measures the urgent paint, which is now fast.
Without useTransition, both updates batch into one render; the expensive one drags the urgent one along; INP suffers.
Does useTransition work with setState from a non-React source (Redux, Zustand)?
Limited. useTransition only marks updates inside its startTransition callback as non-urgent. External stores dispatch synchronously and React schedules their re-renders at the default priority.
For external stores, the equivalent is useDeferredValue(useStore(selector)) — defer the value coming out, even if the store doesn’t know about React’s priorities.
Common mistake — startTransition inside useEffect or async.
startTransition is a synchronous wrapper. Calling setState after an await doesn’t get the transition treatment automatically:
// Wrong — startTransition wraps only the await, not the setState after
async function search(q: string) {
startTransition(async () => {
const r = await fetch(...); // awaits here
setResults(r); // this setState is NOT in the transition
});
}
// Right — wrap the setState specifically
async function search(q: string) {
const r = await fetch(...);
startTransition(() => setResults(r));
}React 19 may relax this for async actions (Server Actions get transition-like behavior). For now, keep startTransition synchronous.
When not to use useTransition?
- The render is already fast (<16ms). Don’t add it preemptively.
- The update is genuinely urgent (the user expects immediate feedback). A confirmed-deletion flash should be sync.
- The state must update atomically with another sync state — splitting via transitions can cause visible inconsistency.
Gotchas
isPendingflicker — for sub-100ms transitions,isPendingflashes briefly. Hide the spinner ifpending < 150ms(use a min-display library) or skip it.- Stale
isPendingafter rapid changes — the latest transition’sisPendingwins; the older one’s already discarded. useDeferredValueinitial render — returns the source value on the first render; lag starts from the second update.- Suspense + transition — a suspended boundary inside a transition shows the previous content (not the fallback) until the new one resolves. Great UX: no flash to fallback for navigation.
- Concurrent rendering doesn’t make slow code fast — it lets fast code stay snappy during slow code. Long render is still long.
startTransitionoutside a component (e.g., in an external store) needs to be imported from React directly and called from a context that has access to the renderer; usually youuseTransitioninside components.
Related
Further reading
- React docs —
useTransition: https://react.dev/reference/react/useTransition - React docs —
useDeferredValue: https://react.dev/reference/react/useDeferredValue - React 18 working group — concurrent rendering: https://github.com/reactwg/react-18/discussions
Interview angle 4
- “What does concurrent rendering actually buy you?” - the ability for React to interrupt low-priority work when a high-priority event arrives. That is the lever for INP: typing stays responsive because the expensive downstream render can be paused and restarted rather than blocking the keystroke.
- “
useTransitionoruseDeferredValue?” - the same idea from two ends.useTransitionsits at the write site: you wrap thesetStatethat triggers expensive work.useDeferredValuesits at the read site: you take a value and get a lagged copy. For a typeahead they are often interchangeable; use the transition when you own the setter and the deferred value when you do not.- Follow-up: “Sketch the typeahead.” - the input’s own
setStateruns urgently so the character appears immediately; the filter-and-render of a large list goes insidestartTransition, so it yields to the next keystroke instead of blocking it.
- Follow-up: “Sketch the typeahead.” - the input’s own
- “Why did your
startTransitionstop working after anawait?” - the transition context does not carry across the await. ThesetStatethat runs after it is outside the transition and is treated as urgent. Wrap thatsetStatein its ownstartTransition. - “What is the visible UX win people miss?” - a
Suspenseboundary inside a transition keeps the previous content on screen while the new view loads, instead of flashing to the fallback. That single behaviour is what makes route changes feel instant rather than blinking.