Part 5 · 5 chapters · ~40 min

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.

25

What "concurrent" means

the question

"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.

the capability and its requirements
  1. Interruptible render: the work loop checks shouldYield between 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).
  2. Prioritised updates: lanes decide what renders first and what waits. Requires: the update queue can hold work for several lanes and rebase (part 4).
  3. 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).
  4. 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).
what changed when, for orientation
  1. React 16 (2017): fiber: the resumable architecture, but rendering was still synchronous.
  2. React 18 (2022): createRoot enables concurrent rendering; startTransition, useTransition, useDeferredValue, useSyncExternalStore, useId; automatic batching; streaming SSR with Suspense; selective hydration.
  3. React 19 (2024): use(), actions and form hooks, ref as a prop, Server Components and the compiler stabilised (part 6); 19.1 and 19.2: <ViewTransition>, <Activity> (the stabilised Offscreen), useEffectEvent.
  4. Legacy mode (ReactDOM.render) renders synchronously and lacks all of this; it was removed in 19.
26

Transitions in practice

code
// 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.
the two APIs
  1. startTransition(fn): every setState inside fn (synchronously; awaited code after an await needs a new startTransition call, 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. useTransition adds isPending.
  2. 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 with useMemo on the expensive derivation so the urgent re-render (same old deferred value) is free.
when it helps and when it does not
  1. 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.
  2. 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).
  3. Changes behaviour: inside a transition, Suspense keeps old content (isPending is 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.
  4. 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 isPending should show it.
the sizes
A transition is worth it when the deferred render takes more than a frame or two (over ~20 ms on the target device). Below that, the two-render cost exceeds the benefit; measure the subtree's render time in the Profiler first.
27

Suspense: thrown promises and boundaries

code
// 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.
the mechanism
  1. Suspending: a component reads something not ready and throws a thenable (via use(), React.lazy, or a data library). The work loop catches it in throwException.
  2. Finding the boundary: walk up the return path to the nearest Suspense fiber. Mark it to capture; attach a ping to the thenable; unwind.
  3. 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: none on 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 (isPending meanwhile).
  4. 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).
  5. Errors: a rejected thenable throws its reason at the read; the nearest error boundary (a class with static getDerivedStateFromError) catches it; Suspense does not.
designing with boundaries
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
SUSPENSE: A THROWN PROMISE
what happens from use(promise) to the fallback and back
swipe the figure sideways, or tap expand for full screen
1/6
use(promise)
Tree: App > Suspense(fallback: Spinner) > Profile > Avatar. Profile calls use(userPromise). The promise is pending. use() throws it (a special SuspenseException wrapping the thenable).
28

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.

the server side
  1. 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>. onShellReady fires when everything outside boundaries is rendered: pipe to the response then.
  2. The shell: must not suspend (or onShellError fires 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.
  3. Order: boundaries resolve in data order, not tree order; the inline script places each correctly regardless. SuspenseList can enforce a reveal order.
  4. Bootstrap: bootstrapScripts or module preloads in the shell so the client bundle loads in parallel with the stream.
the client side
  1. 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).
  2. Dehydrated boundaries: a boundary whose content has not streamed yet (or whose code has not loaded, for lazy components) is left as a DehydratedFragment and hydrated later, when its content arrives or its code loads. The rest of the page becomes interactive meanwhile.
  3. 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.
  4. Priority: hydration of boundaries is otherwise low priority (behind user input), proceeding in tree order in idle slices.
the pointer
Part 13 is hydration in full: mismatches and their causes, islands and partial hydration in other frameworks, and the hybrid and WebView cases. This chapter is the mechanism; that one is the production reality.
STREAMING SSR AND SELECTIVE HYDRATION
the shell first, the slow parts later, the clicked part hydrated first
swipe the figure sideways, or tap expand for full screen
1/6
shell streams
The request arrives. The server renders App; the Posts boundary suspends on a database query; the Comments boundary suspends on another. The stream begins: the HTML for everything outside those boundaries, with their fallbacks (
Loading posts…
) in place. The browser starts painting the shell after the first chunk.
29

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.

the hazard, and the fix
  1. Tearing: a component early in the render reads store.value as 10; the render yields; the store becomes 11; a later component reads 11; the commit shows both.
  2. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot): during render, record getSnapshot() on the hook. At commit, re-read getSnapshot() 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.
  3. The contract: getSnapshot must be cheap and return the same reference for the same state (memoise the selected slice); subscribe must be stable (or it re-subscribes every render); the store must notify synchronously on change.
  4. 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.
what this means for store libraries
  1. Redux's useSelector, Zustand's useStore, Jotai's atoms, MobX's observer: all implemented on useSyncExternalStore (or its shim for older React) since React 18. Selectors are the getSnapshot; the memoisation requirement is why "return a new object from a selector" warns or loops.
  2. Hand-rolled subscriptions (useEffect + useState) tear under transitions. Replace with uSES; it is also fewer lines.
  3. 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.
  4. 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 (useDeferredValue of the selected slice).
the pointer
Part 6 is Server Components and the compiler: components that run only on the server and send a serialised tree, the boundary rules between server and client, server actions, and what the React Compiler memoises. Streaming from this part is the transport Server Components ride on.
TEARING
one store, two values, one tree
swipe the figure sideways, or tap expand for full screen
1/6
render starts
A store (outside React) holds price = 10. A transition render begins: Header reads the store directly (a custom useStore with useEffect + useState) and renders "10". The work loop yields after 5 ms.