#Concurrent rendering
Before React 18, rendering was synchronous and uninterruptible: once React started rendering an update, it went through the whole tree in one go. If that took 200 ms, the main thread was blocked for 200 ms, and keystrokes, clicks and animations waited. Concurrent rendering makes rendering interruptible: React renders in small units of work (one component at a time), checks between them whether something more urgent has arrived, and if so, pauses or throws away the work in progress, handles the urgent update, and comes back later.
The trick that makes this safe is that rendering is not committing. Rendering means calling your components and working out the new tree in memory; committing means applying the result to the DOM. React can render a new screen, keep it half-done, discard it, or render it twice, and the user sees none of that until the commit. That's also why render must be pure: React may call your component and then never use the result.
Mental model: a chef (the main thread) is slowly plating a fancy dessert (a transition). A customer at the counter asks for a glass of water (a click). A pre-18 chef finishes the dessert first; a concurrent chef puts the dessert down, hands over the water, and goes back to the dessert, starting it again if the order changed.
Not every update is interruptible. You tell React which updates can wait by marking them as transitions (startTransition, useTransition) or deferring a value (useDeferredValue). Everything else (clicks, typing) stays urgent and synchronous. This example uses the real scheduler (no act() in the test) to show an interruption happening:
import { memo, startTransition, useLayoutEffect, useState } from "react";
const SlowItem = memo(function SlowItem({ text }) {
const start = performance.now();
while (performance.now() - start < 1) {} // 1 ms of pretend work per item
log.push(`render item ${text}`);
return <li>{text}</li>;
});
export function App() {
const [query, setQuery] = useState(""); // urgent: what the user typed
const [results, setResults] = useState(""); // transition: what the list shows
useLayoutEffect(() => {
log.push(`COMMIT query="${query}" results="${results}"`);
});
const items = results ? Array.from({ length: 200 }, (_, i) => `${results}-${i}`) : [];
return (
<>
<button onClick={() => setQuery((q) => q + "x")}>Type</button>
<p>Query: {query}</p>
<ul>
{items.map((text) => (
<SlowItem key={text} text={text} />
))}
</ul>
</>
);
}
// The test: start a ~200 ms transition, and click 30 ms into it.
startTransition(() => setResults("a"));
setTimeout(() => button.click(), 30);
// log → item renders 1–29, 'COMMIT query="x" results=""', item renders again ×200,
// 'COMMIT query="x" results="a"'startTransition(() => setResults("a"))schedules a transition update. React starts rendering the 200SlowItems, about 1 ms each, and after every few milliseconds of work it yields back to the browser (its scheduler checks the clock between components, in roughly 5 ms slices).- 30 ms in, the click arrives. Because React yielded, the browser can actually run the click handler:
setQueryfrom a click is a discrete (urgent) update. At this point the log shows 29 items rendered for the transition. - On its next slice, React sees a higher-priority update. It throws away the half-built transition tree and renders just the urgent update: the log shows
COMMIT query="x" results="". The user sees their click reflected straight away, while the list still shows the old (empty) results. - Then React restarts the transition from scratch, now with
query = "x"too, and commitsquery="x" results="a". The test counts 229 item renders in total: the 29 discarded plus a full 200. Concurrent rendering spends extra CPU to keep the UI responsive. - Without
startTransition,setResults("a")would be urgent: one blocking 200 ms render, and the click would only be handled after it finished.
The same test with setTimeout(() => setQuery("x"), 30) instead of a click gives a different log: all 200 items render once, then COMMIT query="" results="a", then COMMIT query="x" results="a". The setTimeout update waited for the transition to finish.
The reason is in React's lane priorities. Updates from discrete events (click, keydown, input) get the highest lane; updates from continuous events (mousemove, scroll) the next; updates with no event (a timer, a network callback) get the default lane. In the installed react-dom, getNextLanes keeps rendering the current transition when the new update is in the default lane (32 === suspendedLanes && 0 !== (wipLanes & TransitionLanes) → keep the work in progress). Default updates and transitions are both "not user input", so React doesn't throw away transition work for them. Only input interrupts.
Why the tests for this topic don't use act(): inside act, the installed react-dom renders with its synchronous work loop (null !== ReactSharedInternals.actQueue ? workLoopSync() : workLoopConcurrentByScheduler()), so nothing can interrupt anything. The interruption tests create a root with createRoot directly, set IS_REACT_ACT_ENVIRONMENT = false, and let the real scheduler (5 ms frameInterval) run.
Two more limits worth knowing:
- React can only yield between components. A single component that takes 50 ms to render blocks for 50 ms, transition or not. Concurrency helps when the cost is spread over many components (lists, trees), not one giant one. For one heavy computation, use
useMemo, split the work, or move it to a Web Worker. - React 19.3 untangled transitions. The 19.3 release post lists "Transitions render independently instead of being entangled into one render, so a slow Transition no longer blocks unrelated ones" (react#37290). Before 19.3, several pending transitions were usually rendered together as one batch.