On this page · sections
Concurrent React
Concurrent React lets React interrupt, defer, and prioritize work so urgent updates (typing, clicking) stay responsive while expensive renders catch up. useTransition and useDeferredValue are the primary APIs for marking updates as non-urgent.
| API | Who owns the update | Returns | Controls |
|---|---|---|---|
useTransition | You dispatch the update | [isPending, startTransition] | Render priority |
useDeferredValue | You receive the value | Lagging copy of value | Consumption priority |
| Debounce | Effect timing | Delayed value | When effects fire (ms) |
| Throttle | Effect rate | Capped value | Max effect frequency |
useTransition
Definition
useTransition lets you mark state updates as transitions — lower priority, interruptible renders.
'use client';
import { useMemo, useState, useTransition } from 'react';
function SearchPage() {
const [text, setText] = useState('');
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value;
setText(value); // urgent — input
startTransition(() => setQuery(value)); // non-urgent — list
}
return (
<>
<input value={text} onChange={handleChange} aria-busy={isPending} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Results query={query} />
</div>
</>
);
}
function Results({ query }: { query: string }) {
const items = useMemo(
() => ALL_ITEMS.filter((item) => item.includes(query)),
[query],
);
return (
<ul>
{items.slice(0, 100).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}Both updates come from the same event — split one interaction into urgent and deferrable parts.
isPending
isPending is true from when the transition is scheduled until its render commits. It is a scheduler flag, not a network loading state.
In React 19, startTransition accepts an async function — isPending stays true across await for Actions.
Interview: A transition is priority, not delay. No timer. React starts work immediately at a lower lane.
useDeferredValue
Definition
useDeferredValue returns a lagging copy of a value that React updates in a transition of its own.
'use client';
import { memo, useDeferredValue, useMemo, useState } from 'react';
const HeavyPreview = memo(function HeavyPreview({ text }: { text: string }) {
const blocks = useMemo(
() => Array.from({ length: 5000 }, (_, i) => `${text}-${i}`),
[text],
);
return <p>{blocks.length} blocks for current draft</p>;
});
function Editor({ value, onChange }: {
value: string;
onChange: (next: string) => void;
}) {
const deferred = useDeferredValue(value);
const isStale = deferred !== value;
return (
<>
<textarea value={value} onChange={(e) => onChange(e.target.value)} />
<div aria-busy={isStale} style={{ opacity: isStale ? 0.7 : 1 }}>
<HeavyPreview text={deferred} />
</div>
</>
);
}Use when you receive the value — from props, context, or a router — and do not own the setter. memo on the expensive child is required — otherwise the deferred value saves nothing.
In React 19, an optional initial value lets the first render show something cheap: useDeferredValue(value, '').
Interview:
useTransition= I dispatch.useDeferredValue= I consume. Same scheduler underneath.
Transition vs Debounce
Definition
They operate on different resources:
debounce / throttle → controls how often an effect fires (milliseconds)
transition / deferred → controls render priority (scheduler lanes)'use client';
import { useEffect, useState, useTransition } from 'react';
function useDebouncedValue<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
function Typeahead() {
const [text, setText] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const debouncedQuery = useDebouncedValue(text, 300);
useEffect(() => {
if (!debouncedQuery) return;
const controller = new AbortController();
fetch(`/api/suggest?q=${debouncedQuery}`, { signal: controller.signal });
return () => controller.abort();
}, [debouncedQuery]);
return (
<input
value={text}
onChange={(e) => {
setText(e.target.value);
startTransition(() => setFilter(e.target.value));
}}
aria-busy={isPending}
/>
);
}Debounce protects the network. Transition protects the main thread.
Interview: Transitions do not reduce API calls. Debouncing, caching, and deduplication do.
Transitions and Suspense
Definition
When a component suspends inside a transition, React keeps the previous committed content visible instead of showing the fallback.
'use client';
import { Suspense, use, useState, useTransition } from 'react';
function Tabs() {
const [tab, setTab] = useState('1');
const [isPending, startTransition] = useTransition();
function select(next: string) {
startTransition(() => setTab(next));
}
return (
<>
<button type="button" onClick={() => select('1')}>Tab 1</button>
<button type="button" onClick={() => select('2')}>Tab 2</button>
<div aria-busy={isPending} style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<p>Loading…</p>}>
<TicketView ticketPromise={getTicket(tab)} />
</Suspense>
</div>
</>
);
}
function TicketView({ ticketPromise }: { ticketPromise: Promise<Ticket> }) {
const ticket = use(ticketPromise);
return <h2>{ticket.title}</h2>;
}First render still shows fallback — there is nothing committed to keep. Subsequent tab switches preserve stale UI. Promises must be cached by id — a new promise every render suspends forever.
Interview: Suspense handles pending. Error Boundary handles rejection. Transition keeps stale UI during pending.
What Never Gets Deferred
Definition
Anything the user must see on the same frame as their input:
transition → filter results, tab content, route body, chart re-layout
sync → input value, checkbox state, focus, immediate measurementDeferring controlled input values produces dropped keystrokes.
flushSync is the opposite escape hatch — forces synchronous commit for DOM measurement:
import { flushSync } from 'react-dom';
flushSync(() => setExpanded(true));
panelRef.current?.scrollIntoView();Use sparingly. Never pair flushSync with a transition for the same state. Standalone startTransition imported from react works outside components — useful for store setters.
Interview: Reduce work first (virtualize, memoize). Transitions manage latency for work that remains.
Senior Interview Summary
What to say
useTransitionmarks updates you dispatch as non-urgent.useDeferredValuereturns a lagging copy of a value you receive. Both use the concurrent scheduler — priority, not delay. Pair withmemoon expensive children and debounce for network protection.
Best mental model
useTransition → "This update can wait."
useDeferredValue → "I'll read the old value until React catches up."
debounce → "Wait 300ms of silence before firing the effect."Practical rule
Own the setter → useTransition in event handler
Receive value as prop → useDeferredValue + memo on expensive child
API calls per keystroke → debounce the fetch key, not the render
Tab switch with fetch → startTransition + cached promise + Suspense
Controlled input → never defer the input state itself
12k DOM nodes → virtualize first, then transition