MENU
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.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>
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
});
});
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>
</>;
}
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>
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.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>;
}
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>;
}
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);
}
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>
</>;
}
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>
</>;
}