Concurrent Rendering

Everything so far has treated a render as one indivisible job: call a setter, React walks the tree, the DOM is updated. The main thread does one thing at a time, so while React renders two thousand rows the browser cannot paint the character just typed. The page freezes for as long as the render takes.Concurrent rendering drops that assumption. Since React 18 the renderer can prepare an update in memory, stop partway to let the browser handle something urgent, resume, and discard the half-built result if it has become obsolete. Nothing reaches the DOM until a render is complete, so the user never sees a half-updated screen. In exchange you can mark an update not urgent.Figure 2.31: A blocking render freezes input; an interruptible one yields between units of workThe APIs here reach that scheduler: two ways to mark a Transition, a way to lag a value behind, the boundary that catches a component whose data is missing, and the hooks that stay correct when a render can be discarded.

2.8.1 What Concurrent Rendering Changes

Calling createRoot turns the concurrent renderer on, but nothing becomes concurrent by itself. A plain setState is still an urgent update, rendered in one pass and committed as fast as React can. Concurrency is opt-in per update, in three ways: wrap a setter in startTransition, read a value through useDeferredValue, or suspend inside a <Suspense> boundary. Behavior Urgent update Transition update Can React pause it? no yes, between components Can React discard it? no yes, if a newer update arrives Suitable for input values, toggles, focus filtering, tab switches, navigation Table 2.14: Urgent and Transition updates compared Two consequences are easy to miss. A render React abandons has already run your component function, possibly several times, without reaching the DOM — which is why the rules in Section 2.2.8 matter here: a component that mutates a module variable while rendering does it once per attempt. And data living outside React can change between two slices of one render, leaving half the tree on the old value — tearing, which Section 2.8.8 prevents.

2.8.2 useTransition

useTransition returns the pair [isPending, startTransition]. Every setter called directly inside the callback you pass to startTransition is marked as a Transition: React may render it in the background, pause it, and abandon it if a newer update makes it irrelevant. isPending is true while one of this component's Transitions is in flight — hang a spinner or a dimmed style on it.The demo runs the same search twice: the left copy sets the query with a plain setter, the right copy wraps that setter in startTransition. A script types three characters 100 ms apart into each input, and every render of the expensive list writes a line into the log beneath it.

<div id="root"></div>

<style>body{font:13px system-ui;margin:6px} pre{background:#eceff1;padding:4px;font-size:12px}

.row{display:flex;gap:10px} .row>div{flex:1}</style>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">

{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }

</script>

<script type="text/babel" data-type="module">

import React, { useState, useTransition } from 'react';

import { createRoot } from 'react-dom/client';

const log = { plain: [], transition: [] };

function SlowList({ query, tag }) {

let x = 0; for (let i = 0; i < 4e6; i++) x += Math.sqrt(i); // ~25 ms of real work

log[tag].push(` list rendered for "${query}"`);

return <p>{x > 0 ? 200 : 0} rows matching "{query}"</p>;

}

const Log = ({ tag }) => <pre>{log[tag].join('\n')}</pre>; // renders after SlowList

function Search({ tag, plain }) {

const [text, setText] = useState(''), [query, setQuery] = useState('');

const [isPending, startTransition] = useTransition();

function onChange(e) {

const v = e.target.value;

setText(v); // urgent either way

log[tag].push(`keystroke "${v}"`);

if (plain) setQuery(v); else startTransition(() => setQuery(v));

}

return <div><b>{tag}</b> <input value={text} onChange={onChange} size="8" />

{isPending && ' filtering...'}<SlowList query={query} tag={tag} /><Log tag={tag} /></div>;

}

createRoot(document.getElementById('root')).render(

<div className="row"><Search tag="plain" plain /><Search tag="transition" /></div>);

const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;

[0, 1].forEach((n) => 'lam'.split('').forEach((c, i) => setTimeout(() => {

const el = document.querySelectorAll('input')[n];

set.call(el, el.value + c);

el.dispatchEvent(new Event('input', { bubbles: true }));

}, 300 + n * 700 + i * 100)));

</script>

Listing 2.59: The same search with and without a Transition Figure 2.32: Browser output of Listing 2.59On the left each keystroke is followed by one list render, for the character just typed: setter and expensive render share a blocking pass, so the letter cannot appear until the list is finished. On the right each keystroke produces two renders — one for the previous query (the urgent pass that puts the new letter in the input and leaves the old list on screen), then a low-priority one that catches the list up.Three rules follow. Never set the value bound to an input inside a Transition, or the field lags behind the keyboard. Only synchronous setters are marked, so one called from a setTimeout is urgent again. And this is no debounce: every intermediate query is still rendered.

2.8.3 The Standalone startTransition

useTransition is a hook, so it runs only inside a component or a custom hook. React also exports a plain startTransition that schedules a Transition the same way from anywhere: a data module, a WebSocket handler, a router, a listener attached outside React. It has no call site of its own to report from, so there is no isPending flag.

import { startTransition } from 'react';

let results = [];

const listeners = new Set();

export const subscribe = (fn) => (listeners.add(fn), () => listeners.delete(fn));

export const getResults = () => results;

const socket = new WebSocket('wss://feed.example.com/scores');

socket.addEventListener('message', (event) => {

startTransition(() => { // a push from the server is never urgent

results = JSON.parse(event.data);

listeners.forEach((fn) => fn()); // components re-render at low priority

});

});

Listing 2.60: Marking an update from plain code outside React Components read this store through useSyncExternalStore (Section 2.8.8). Because the notification happens inside startTransition, a burst of messages cannot lock up a user typing on the same page. Since React 19 the callback may be async, which is what makes Actions (Section 2.9.2) possible; setters after the first await still need their own nested startTransition. Tip: isPending only reports Transitions started through that component's own useTransition call, so keep a spinner and the update that drives it in the same component.

2.8.4 useDeferredValue

useDeferredValue(value, initialValue?) hands you a copy of value that lags behind. On the render where value changes React returns the previous value again, then schedules a low-priority render with the new one and swaps it in when ready. Where useTransition marks an update you make, this hook takes a value you may not control — a prop from a parent, or state whose setter lives elsewhere.

import { memo, useDeferredValue, useState } from 'react';

const SlowList = memo(function SlowList({ query }) {

const rows = items.filter((t) => t.includes(query)); // expensive for 10,000 items

return <ul>{rows.map((t) => <li key={t}>{t}</li>)}</ul>;

});

export default function Search() {

const [query, setQuery] = useState('');

const deferred = useDeferredValue(query);

const isStale = query !== deferred; // true while the list catches up

return <>

<input value={query} onChange={(e) => setQuery(e.target.value)} />

<div style={{ opacity: isStale ? 0.5 : 1 }}><SlowList query={deferred} /></div>

</>;

}

Listing 2.61: Filtering against a value that lags one keystroke behind memo is not optional here. Without it the list body re-runs on every render of Search whether or not its query prop changed, and the deferred value buys nothing. The isStale comparison is the idiomatic way to dim content that is one step behind.The second argument, added in React 19, is returned on the first render only, when there is no previous value: useDeferredValue(items, []) renders an empty list immediately and the real one right after, instead of blocking the first paint.

2.8.5 Suspense

A component suspends when it needs something that is not ready — a promise read with use, a lazily imported module, data loaded by a framework. React walks up to the nearest <Suspense> boundary and renders its fallback in place of the subtree, leaving everything above and beside it untouched.

<div id="root"></div>

<style>body{font:14px system-ui} .card{border:1px solid #90a4ae;padding:2px 8px;margin:4px}</style>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">

{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }

</script>

<script type="text/babel" data-type="module">

import React, { Suspense, use } from 'react';

import { createRoot } from 'react-dom/client';

const after = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));

const profile = after(50, 'Ada Lovelace'); // resolves before the page is captured

const orders = after(60000, 'too late'); // still pending when it is captured

const Row = ({ label, promise }) => <p>{label}: <b>{use(promise)}</b></p>;

createRoot(document.getElementById('root')).render(<div>

<div className="card"><Suspense fallback={<p>Loading profile...</p>}>

<Row label="Name" promise={profile} /></Suspense></div>

<div className="card"><Suspense fallback={<p>Loading orders...</p>}>

<Row label="Orders" promise={orders} /></Suspense></div>

<p>Everything outside the boundaries stayed on screen and interactive.</p></div>);

</script>

Listing 2.62: Two boundaries, one resolved and one still waiting Figure 2.33: Browser output of Listing 2.62Where the boundaries go is a design decision: one around the page gives a single spinner and an all-or-nothing reveal, one per card lets each part appear as its data lands. An update that would push an already visible boundary back to its fallback is suppressed when you wrap it in a Transition. And a boundary only catches components that suspend inside it, so a component cannot be both the boundary and the suspending child. Note: Suspense catches missing data, not errors. A rejected promise passes straight through to the nearest error boundary, so production trees pair the two: an error boundary outside, a <Suspense> within it.

2.8.6 The use Hook

use reads a resource: a promise or a context. It breaks the usual hook rules — it may be called inside an if, inside a loop, or several times in one component — because it does not depend on call order. Given a promise it returns the resolved value; while the promise is pending it suspends the component, so the nearest <Suspense> boundary takes over. A rejected promise reaches the nearest error boundary, which is why use must never be wrapped in try/catch.The rule that catches everybody: the promise must be the same object on every attempt. React discards the state of a component that suspends before mounting and renders it from scratch, so a promise created during render is created anew each time and the component suspends forever.

import { use, Suspense } from 'react';

const cache = new Map();

function getUser(id) { // one promise per id, reused across renders

if (!cache.has(id)) {

cache.set(id, fetch(`https://api.example.com/users/${id}`).then((r) => r.json()));

}

return cache.get(id);

}

function Profile({ id }) {

const user = use(getUser(id)); // suspends only on the first attempt

return <h2>{user.name}</h2>;

}

export default function App({ id }) {

return <Suspense fallback={<p>Loading profile...</p>}><Profile id={id} /></Suspense>;

}

Listing 2.63: Caching the promise so use can resolve it Reading context with use(ThemeContext) behaves like useContext but can be called conditionally. React 19.3 added a third resource: use(browser()), imported from react-dom, suspends during server rendering only, so a widget that needs window shows its fallback on the server and the real thing in a browser. Warning: In production, prefer a cache owned by a library — TanStack Query (Section 2.19.9) or a framework's data layer (Chapter 5). A hand-rolled Map never invalidates.

2.8.7 useId

useId() returns a string unique to that call in that component instance and stable across re-renders. It exists for attributes that must point at each other — htmlFor/id, aria-describedby — inside a component that may appear many times on one page, where a hardcoded id would collide. One call seeds a family of ids.

function PasswordField() {

const id = useId();

return <p>

<label htmlFor={id + '-in'}>Password </label>

<input id={id + '-in'} type="password" aria-describedby={id + '-hint'} />

<span id={id + '-hint'}>At least 12 characters.</span>

</p>;

}

Listing 2.64: One call, one id family per instance Render that component twice and React generates _r_0_ and _r_1_, so the two aria-describedby pairs never collide. The shape is deliberate: React 19.2 replaced the older :r0: prefix because a colon is not valid in a CSS selector or an XML name, and these ids work with document.querySelector('#' + id) unescaped.The value comes from the component's position in the tree, not a counter. That is what makes server rendering work: server and client produce identical ids while they render the same tree, and diverging trees produce a hydration mismatch. Two things useId is not for: list keys, which come from your data (Section 2.2.4), and cache keys for use.

2.8.8 useSyncExternalStore

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) reads a value React does not own — a browser API, a Redux store, a third-party cache — and re-renders when it changes. subscribe registers a callback and returns an unsubscribe function; getSnapshot reads the value and must be cheap.

import { useSyncExternalStore } from 'react';

function subscribe(onChange) {

window.addEventListener('online', onChange);

window.addEventListener('offline', onChange);

return () => {

window.removeEventListener('online', onChange);

window.removeEventListener('offline', onChange);

};

}

export function useOnlineStatus() { // third argument: the value for SSR

return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);

}

Listing 2.65: A hook over a browser API that cannot tear The older pattern — subscribe in an effect, force a re-render by hand — breaks in exactly the case this section is about. A paused render can resume after the external value changed, so components rendered before the pause show the old value and those after the new one. This hook prevents that tearing by re-reading the snapshot at commit time and re-rendering synchronously if it moved.Two failure modes account for nearly every bug here. If getSnapshot builds a new object on each call — () => ({ count: store.count }) — Object.is never matches and the component re-renders forever; return a primitive or a stable reference. And omitting getServerSnapshot makes server rendering throw, because there is no window to read.React Redux 9 and Zustand call this hook internally, which is how they stay correct under concurrent rendering. Reach for it directly only to wrap a store with no React bindings of its own.

2.8.9 Activity

<Activity mode="hidden"> hides a subtree without unmounting it. React applies display: none to the children and tears down their effects — subscriptions, timers and sockets stop — but keeps their DOM and their state. Switch the mode back to visible and the state is where the user left it, effects re-created. Hidden children still re-render on new props, below the priority of anything visible.

import { Activity, useState } from 'react';

export default function Workspace() {

const [tab, setTab] = useState('search');

return <>

<button onClick={() => setTab('search')}>Search</button>

<button onClick={() => setTab('map')}>Map</button>

<Activity mode={tab === 'search' ? 'visible' : 'hidden'}><SearchPanel /></Activity>

<Activity mode={tab === 'map' ? 'visible' : 'hidden'}><MapPanel /></Activity>

</>;

}

Listing 2.66: Two panels that keep their state while hidden Written as {tab === 'map' && <MapPanel />}, switching tabs throws away the scroll position, the zoom level and every input in the panel. Hidden with a CSS class instead, the state survives but the effects keep running: the map keeps polling, the socket stays open. Activity is the combination you want, and the supported way to pre-render a screen the user is likely to open next.Two caveats. A hidden Activity whose child renders only text produces no DOM output, because there is no element to hide. And DOM-level side effects survive hiding: a playing <video> keeps playing unless a cleanup pauses it. <Activity> is stable as of React 19.2, released 1 October 2025.

2.8.10 View Transitions

<ViewTransition> animates a subtree as it enters, leaves, moves or changes, driving the browser's View Transition API from React's own scheduler. It became a stable export of react in React 19.3, released 9 September 2026, after eighteen months in the experimental channel. Wrapping a subtree is enough for the browser's default cross-fade; the enter, exit, update and share props take View Transition Classes, styled with ::view-transition-new(.class) rules.One point connects this to the rest of the section: a plain setState never activates a view transition. Only an update inside a Transition, a Suspense reveal, or a change flowing through useDeferredValue does — the updates React can hold back until the animation is ready.

import { ViewTransition, addTransitionType, startTransition, useState } from 'react';

export default function Slideshow({ slides }) {

const [i, setI] = useState(0);

const go = (step, type) => startTransition(() => {

addTransitionType(type); // 'next' or 'previous'

setI((n) => (n + step + slides.length) % slides.length);

});

return <>

<button onClick={() => go(-1, 'previous')}>Prev</button>

<button onClick={() => go(1, 'next')}>Next</button>

<ViewTransition key={i} enter={{ next: 'from-right', previous: 'from-left' }}

exit={{ next: 'to-left', previous: 'to-right' }}>

<img src={slides[i]} alt="" />

</ViewTransition>

</>;

}

Listing 2.67: A slideshow that animates in the direction you moved addTransitionType tags the update so one boundary animates differently depending on what caused it — forward and back navigation being the standard case. A <ViewTransition> must sit outside the DOM node it animates, or React cannot attach enter and exit animations. Give a name only to a shared element moving between two places, and mount one element with that name at a time. Warning: Wrap animations in @media (prefers-reduced-motion: no-preference), or disable them with ::view-transition-group(*) { animation: none; } under the opposite query. The browser reports that setting for exactly this reason.