Concurrent Rendering
Nothing runs in parallel; a render can be paused, resumed, and discarded, and everything in this part is a use of that. Transitions and deferred values in practice, Suspense as a thrown promise caught by a boundary and retried on a ping, streaming SSR with per-boundary hydration and the clicked boundary first, and tearing with the one hook that prevents it.
What "concurrent" means
"Concurrent mode, concurrent features, concurrent rendering. What is actually concurrent?"
Rendering. React can start rendering an update, pause partway (the work loop yields; part 3), let the browser handle input and paint, resume, or throw the partial render away if something more urgent arrived. Nothing runs in parallel; two renders are never in progress at once. What is concurrent is that a render and the user's interaction can be interleaved in time. Every feature in this part (transitions, Suspense, streaming, selective hydration) is a use of that one capability, and every rule in the earlier parts (pure render, effects after commit, stable references) exists so it is safe.
- Interruptible render: the work loop checks
shouldYieldbetween fibers; a higher lane discards the work in progress (part 3). Requires: render has no side effects (it may run again), the current tree is untouched until commit (the two trees, part 1), state updates are queued not applied (part 4). - Prioritised updates: lanes decide what renders first and what waits. Requires: the update queue can hold work for several lanes and rebase (part 4).
- Suspending render: a component can say "not yet" by throwing a promise; the render continues elsewhere and comes back. Requires: a boundary to show meanwhile and a way to retry (this part).
- Consistent commits: everything the user sees was computed from one version of state. Requires: React-owned state is versioned; external reads go through
useSyncExternalStore(this part).
- React 16 (2017): fiber: the resumable architecture, but rendering was still synchronous.
- React 18 (2022):
createRootenables concurrent rendering;startTransition,useTransition,useDeferredValue,useSyncExternalStore,useId; automatic batching; streaming SSR with Suspense; selective hydration. - React 19 (2024):
use(), actions and form hooks,refas a prop, Server Components and the compiler stabilised (part 6); 19.1 and 19.2:<ViewTransition>,<Activity>(the stabilised Offscreen),useEffectEvent. - Legacy mode (
ReactDOM.render) renders synchronously and lacks all of this; it was removed in 19.
Transitions in practice
// transitions in practice
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
function onChange(e) {
setQuery(e.target.value) // SyncLane: the input updates this frame
startTransition(() => setFilter(e.target.value)) // TransitionLane: the 10,000-row filter follows, interruptible
}
// isPending: true until the transition commits. show opacity: 0.6 on the list, not a spinner (the old list stays visible)
// useDeferredValue: when the expensive consumer owns the deferral
function Results({ query }) {
const deferred = useDeferredValue(query) // first render: old value (urgent); second: new value (transition lane)
const rows = useMemo(() => filter(all, deferred), [deferred]) // memo so the urgent re-render with the old value is free
return <List rows={rows} stale={deferred !== query} />
}
// what a transition does NOT do: make the work faster. a 60 ms filter is still 60 ms of CPU; it is now in 5 ms slices with input between.
// what it does: keep the input responsive, keep the old content visible, let a newer keystroke cancel an older filter render.
// Suspense inside a transition: a boundary that already has content keeps it (no fallback flash); a new boundary shows its fallback.
// navigation (React Router, Next.js): wrapped in startTransition by the framework, so the old page stays until the new one is ready.startTransition(fn): everysetStateinsidefn(synchronously; awaited code after anawaitneeds a newstartTransitioncall, or in React 19 async transitions handle it) gets a transition lane. The update is deferrable and interruptible; a boundary that suspends inside it keeps old content instead of showing a fallback.useTransitionaddsisPending.useDeferredValue(value, initial?): returns the previous value during the urgent render and schedules a transition render with the new one. For when the expensive render consumes a value it does not set. Pair withuseMemoon the expensive derivation so the urgent re-render (same old deferred value) is free.
- Helps: a large subtree re-render triggered by frequent input (search filtering, tab switches with heavy content, chart updates on slider drag, navigation). The input stays responsive; the old content stays visible; stale renders are cancelled by newer ones.
- Does not help: work that is not React rendering (a synchronous 200 ms computation in a handler: move it to a worker or slice it yourself); a small subtree (the sync render was already fast); a change that the user must see immediately (their own input).
- Changes behaviour: inside a transition, Suspense keeps old content (
isPendingis the signal to dim it); outside, a fallback shows. Navigation libraries wrap route changes in transitions for exactly this: the old page stays until the new one has its data. - Costs: two renders instead of one when the value changes (urgent with the old deferred value, then the transition); interrupted transitions redo their work (part 3); a transition can lag several frames under continuous input, which is the point, and
isPendingshould show it.
Suspense: thrown promises and boundaries
// Suspense: the boundary decides what hides; the promise decides when
<Suspense fallback={<PageSkeleton />}> // the whole page hides if anything inside suspends
<Header />
<Suspense fallback={<ListSkeleton />}> // nest: only the list hides when its data is pending
<List /> // List reads with use(listPromise) or a suspense-enabled data hook
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Suspense>
// the promise must be stable: cache it outside render
const cache = new Map()
function fetchUser(id) { if (!cache.has(id)) cache.set(id, fetch(`/api/users/${id}`).then(r => r.json())); return cache.get(id) }
function Profile({ id }) { const user = use(fetchUser(id)); return <h1>{user.name}</h1> } // same promise object on the retry → resolves
// SuspenseList (experimental) orders reveals (forwards, backwards, together) so streamed boundaries do not pop in randomly.
// errors: a rejected promise throws at use(); the nearest Error Boundary catches it (class with componentDidCatch / static getDerivedStateFromError).
// lazy(): a component whose module import is a promise; Suspense handles the loading; the retry renders the resolved component.- Suspending: a component reads something not ready and throws a thenable (via
use(),React.lazy, or a data library). The work loop catches it inthrowException. - Finding the boundary: walk up the return path to the nearest
Suspensefiber. Mark it to capture; attach a ping to the thenable; unwind. - Rendering the fallback: the boundary renders its fallback as the visible child; the primary children become a hidden Offscreen subtree (existing fibers and DOM kept with
display: noneon update; not created on initial mount). Outside a transition, this commits. Inside a transition on a boundary that already showed content, the commit is delayed until the promise resolves (isPendingmeanwhile). - Retrying: the ping fires when the thenable settles; a RetryLane is marked; the boundary's subtree re-renders; the read now returns the value; the content commits and the fallback is removed (or the hidden subtree revealed).
- Errors: a rejected thenable throws its reason at the read; the nearest error boundary (a class with
static getDerivedStateFromError) catches it; Suspense does not.
- A boundary is the unit of loading UI. Everything under it hides together; nest to scope. Put boundaries where a skeleton makes sense to a user (a card, a list, a panel), not around every component.
- Fallback flashes: a boundary that resolves in 50 ms shows a spinner for 50 ms, which reads as a flicker. Wrap the triggering update in a transition (old content stays), or give the boundary content that was already loaded, or accept it for genuinely slow data.
- Promise identity: the thenable must be the same object on retry. A cache keyed by the request (the code above) or a data library.
use(fetch(url))inline creates a new promise per render and suspends forever. - Waterfalls: a parent that suspends, then a child that suspends after the parent resolves, is two round trips. Start all requests before rendering (render-as-you-fetch: part 8) or hoist them to the route.
- Layout effects and refs under a hidden boundary do not run until it is revealed; passive effects of hidden content are deferred too (Offscreen semantics). State is preserved.
Streaming SSR and selective hydration
Server rendering used to be all or nothing: wait for all data, send all HTML, then hydrate everything in tree order before anything was interactive. With Suspense on the server, the HTML streams: the shell first, each boundary's content as its data resolves, swapped in by a few bytes of inline script. On the client, hydration proceeds per boundary, and a boundary the user interacts with is hydrated first.
renderToPipeableStream(Node) /renderToReadableStream(Web streams): render the tree; when a component suspends under a boundary, emit the boundary's fallback with markers (<!--$?-->, a<template id>) and continue; when its promise resolves, emit the content in a hidden div plus<script>$RC(id, id)</script>.onShellReadyfires when everything outside boundaries is rendered: pipe to the response then.- The shell: must not suspend (or
onShellErrorfires and you fall back to client rendering). Put slow data behind boundaries; put the frame of the page, navigation and whatever is fast in the shell. - Order: boundaries resolve in data order, not tree order; the inline script places each correctly regardless.
SuspenseListcan enforce a reveal order. - Bootstrap:
bootstrapScriptsor module preloads in the shell so the client bundle loads in parallel with the stream.
hydrateRoot(container, element): walks the existing DOM alongside a render, attaching fibers to nodes and handlers to elements, without creating DOM. A mismatch (text or attribute differs) is an error in development and, in production, a client re-render of the subtree (React 18+ recovers per boundary).- Dehydrated boundaries: a boundary whose content has not streamed yet (or whose code has not loaded, for
lazycomponents) is left as a DehydratedFragment and hydrated later, when its content arrives or its code loads. The rest of the page becomes interactive meanwhile. - Selective hydration: a discrete event (click, keydown) inside a dehydrated boundary marks it for synchronous hydration (SyncHydrationLane), hydrates it before continuing elsewhere, and replays the event. Continuous events (hover) raise priority without replay. The user's first interaction is served even if hydration had been proceeding elsewhere.
- Priority: hydration of boundaries is otherwise low priority (behind user input), proceeding in tree order in idle slices.
Tearing and external stores
Concurrent rendering has one new correctness hazard: a render that yields can observe a mutable external value before and after it changes, and commit a tree whose parts disagree. State React owns cannot do this (updates are queued per lane and applied per render); state outside React can. useSyncExternalStore is the hook that makes external reads consistent, and every store library's React binding is built on it.
- Tearing: a component early in the render reads
store.valueas 10; the render yields; the store becomes 11; a later component reads 11; the commit shows both. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot): during render, recordgetSnapshot()on the hook. At commit, re-readgetSnapshot()for every such hook in the committed tree; if any differs, schedule a synchronous re-render of that fiber (SyncLane) before the frame is painted. On mount, subscribe in a passive effect and check once more for changes that happened between render and subscription.- The contract:
getSnapshotmust be cheap and return the same reference for the same state (memoise the selected slice);subscribemust be stable (or it re-subscribes every render); the store must notify synchronously on change. - Cost: O(uSES fibers) extra reads per commit, and a second render when a tear would have occurred. Under synchronous rendering (sync lanes) no yield happens, so no tear and no extra render.
- Redux's
useSelector, Zustand'suseStore, Jotai's atoms, MobX's observer: all implemented onuseSyncExternalStore(or its shim for older React) since React 18. Selectors are thegetSnapshot; the memoisation requirement is why "return a new object from a selector" warns or loops. - Hand-rolled subscriptions (
useEffect+useState) tear under transitions. Replace with uSES; it is also fewer lines. - Reads of
window,document,localStorage,Date, globals during render are external reads too: they can tear and they break hydration. Through uSES with a server snapshot, or in an effect. - Transitions and stores: a store update is always a sync re-render via uSES, never a transition. A store-driven filter of 10,000 rows is therefore not interruptible. To make it so, copy the store value into React state inside a transition, or let the deferral happen on the derived value (
useDeferredValueof the selected slice).