How do concurrent rendering and transitions work?
Concurrent rendering lets React prepare multiple UI versions in the background. `useTransition` marks a state update as non-urgent so React can interrupt it for higher-priority work like typing.
Concurrent rendering is React 18's headline change, and it's a model shift, not a single API. Pre-18, every setState was "urgent": React started rendering, ran the whole tree to completion synchronously, and committed. A 100ms render meant 100ms of unresponsive UI. Post-18, React assigns each update to a lane (priority bucket on a 31-bit bitmask), and the reconciler is allowed to:
- Pause an in-progress render between fibers and check for higher-priority work.
- Restart rendering at a higher priority — discarding the in-progress WIP tree because render is pure.
- Branch: render the next tree in memory while keeping the current tree on screen (no flicker).
- Drop: skip rendering for an interrupted-then-superseded update.
This is only safe because the render phase is pure (no side effects); commits remain atomic and synchronous.
Lanes (high → low priority): SyncLane (legacy sync), InputContinuousLane (text input, hover), DefaultLane (most updates), TransitionLane(s) (startTransition), IdleLane, OffscreenLane. Multiple updates can be batched into the same lane; React always works on the highest-priority lane with pending work.
useTransition explicitly tags a state update as non-urgent:
const [isPending, startTransition] = useTransition();
startTransition(() => setFilter(input));What happens: React commits any urgent updates that arrive (the controlled input value, hover styles) at high priority and renders the transition update in the background. isPending is true until the transition lands, letting you show a subtle "loading" state without blocking input. The killer use case: filtering a 10,000-item list as the user types — the input feels instant because typing is urgent, the filtered list catches up when there's slack.
useDeferredValue(value) is the value-side equivalent. It returns a version of value that lags — initially equal to the new value, but React may render with the old value first and re-render with the new value at lower priority. Useful when the expensive component is a leaf you don't control (a chart, a code editor) and you can't move its setState behind startTransition.
const deferredQuery = useDeferredValue(query);
return <HeavyResults query={deferredQuery} />;Automatic batching. React 18 also batches all updates within the same event/microtask, even across async boundaries (promises, timeouts, native handlers). Pre-18, only React-synthetic event handlers batched. Together with concurrency, this dramatically reduces unnecessary intermediate renders.
Suspense + concurrency. A component can throw a Promise during render. React unwinds the WIP tree to the nearest <Suspense fallback> and either (a) keeps the old UI visible while loading the new tree at low priority, or (b) shows the fallback if the boundary is fresh. Resuming when the promise settles is straightforward because rendering is restartable. This is how the App Router streams: each <Suspense> is a streaming chunk.
Practical gotchas:
- Render must be pure. Side effects in render (mutating refs, logging, fetching) may now run multiple times — StrictMode in dev intentionally double-invokes components to surface this.
- External stores need
useSyncExternalStoreto avoid tearing: during concurrent rendering, two parts of the tree might otherwise read different snapshots of a Zustand/Redux store. - Transitions don't make slow code fast — they just keep urgent work responsive. The total CPU still has to be paid. Pair transitions with memoization (
React.memo,useMemo) so background renders don't re-do work. - You can't
startTransitionaround input value updates — text-input feedback must be urgent or it feels broken. The pattern is "urgent input → deferred derivation." isPendingflips many times during a long transition, so use it to gate a non-disruptive spinner, not a full skeleton.
In short: concurrency turns React's reconciler from "always sync" into a scheduler that respects user input above background work. useTransition / useDeferredValue are the two opt-ins; Suspense is the I/O integration.
Code
Follow-up questions
- •What's the difference between useTransition and useDeferredValue?
- •Why does React need useSyncExternalStore?
- •How does Suspense interact with transitions?
Common mistakes
- •Wrapping every update in startTransition — defeats the purpose.
- •Putting input value updates inside startTransition (input feels laggy).
Performance considerations
- •Transitions don't make work cheaper — they just deprioritize it.
- •If the urgent path itself is slow, transitions won't help.
Edge cases
- •Synchronous DOM reads (`element.offsetHeight`) inside a transition can force layout and ruin pacing.
- •Suspense fallbacks inside transitions are suppressed if the previous content is still showable.
Real-world examples
- •Linear's instant-feeling search uses transitions to keep typing smooth while filtering 100k+ items.