Async UI all 3 written
Suspense and lazy, promises as data with use(), and error boundaries for the things that go wrong.
25 Suspense and lazy
Some things a component needs are not there yet: code that has not been
downloaded, data that has not arrived. Rather than make every component handle "not ready", React lets
a component suspend, and lets a parent, called a Suspense boundary, choose
what to show while it waits. This lesson uses lazy to suspend, the simplest case, and
the next one uses data.
lazy: code that loads on first use
lazy(load) takes a function that returns a promise of a module with a
default export, the shape that a dynamic import() gives you. When the lazy
component is first rendered, React calls the function and, until the promise resolves, the nearest
Suspense above it shows its fallback. The panel does not import anything:
it holds a promise open and releases it, so the stage text can be read in both states.
lazy.jsx
import { Suspense, lazy, act } from 'react';
import { createRoot } from 'react-dom/client';
let release;
const held = new Promise((resolve) => {
release = () => resolve({ default: function Greeting() { return <p>loaded</p>; } });
});
const Greeting = lazy(() => held);
const root = createRoot(stage);
const seen = [];
await act(async () =>
root.render(
<Suspense fallback={<p>loading</p>}>
<Greeting />
</Suspense>,
),
);
seen.push(stage.textContent);
await act(async () => release());
seen.push(stage.textContent);
console.log(seen);
check('first the fallback, then the component', seen, ['loading', 'loaded']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node lazy.jsx
[ 'loading', 'loaded' ] ok first the fallback, then the component
In a real project the function is () => import('./Greeting.jsx'), and a bundler
such as Vite turns that into a separate file that the browser fetches the first time the component
is shown. That is how a large app avoids sending all of its code to a visitor who opens one page.
Boundaries are independent
A boundary only covers what is inside it. Two boundaries side by side wait separately, so a slow part of the page does not hold back a fast one, and the rest of the page, outside any suspended component, is shown straight away. Put a boundary around each part you want to appear on its own.
nested.jsx
import { Suspense, lazy, act } from 'react';
import { createRoot } from 'react-dom/client';
const gates = {};
function later(name) {
return lazy(() => new Promise((resolve) => {
gates[name] = () => resolve({ default: () => <p>{name} ready</p> });
}));
}
const Left = later('left');
const Right = later('right');
const root = createRoot(stage);
const seen = [];
await act(async () =>
root.render(
<main>
<h1>page</h1>
<Suspense fallback={<p>left loading</p>}><Left /></Suspense>
<Suspense fallback={<p>right loading</p>}><Right /></Suspense>
</main>,
),
);
const text = () => Array.from(stage.querySelectorAll('h1, p')).map((n) => n.textContent).join(' | ');
seen.push(text());
await act(async () => gates.left());
seen.push(text());
await act(async () => gates.right());
seen.push(text());
console.log(seen);
check('the heading is there at once, and both parts wait', seen[0], 'page | left loading | right loading');
check('releasing one loads only that one', seen[1], 'page | left ready | right loading');
check('and then the other', seen[2], 'page | left ready | right ready');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node nested.jsx
[ 'page | left loading | right loading', 'page | left ready | right loading', 'page | left ready | right ready' ] ok the heading is there at once, and both parts wait ok releasing one loads only that one ok and then the other
On the server, with nothing to wait for
renderToString is the simplest server render (lesson 30). A boundary whose child
has nothing to wait for is not visible in the text: only two comment markers are left, so that the
browser can find the boundary again when it hydrates. The fallback is never written, because it was
never needed.
server.jsx
import { Suspense } from 'react';
import { renderToString } from 'react-dom/server';
function Ready() {
return <p>ready</p>;
}
const html = renderToString(
<Suspense fallback={<p>loading</p>}>
<Ready />
</Suspense>,
);
console.log(html);
check('the content, between the markers', html, '<!--$--><p>ready</p><!--/$-->');
check('no fallback in the HTML', html.includes('loading'), false);
What node printed (React 19.2.7, development build), when the build ran it
node server.jsx
<!--$--><p>ready</p><!--/$--> ok the content, between the markers ok no fallback in the HTML
Promises are the subject of the Node.js course's promises and async/await lessons: a boundary is just React waiting for one.
lazy(() => import(...))loads a component on first use.- The nearest
Suspenseabove a suspended component shows itsfallback. - Boundaries are independent; put one around each part that should appear on its own.
- With nothing to wait for, a boundary leaves only comment markers in server HTML.
26 use() and promises as data
Components render synchronously, but data arrives later. The older answer
was an effect that fetches and sets state, with a "loading" flag to keep track. React 19's
use is simpler: hand it a promise, and the component reads the value as if it were
already there. If the promise is not ready, the component suspends and the nearest
Suspense boundary shows its fallback.
Reading a promise
The promise has to exist before the component renders and must be the same promise on every render. A promise made inside the component is a new one each time, so React would wait for a new promise forever. Here it is made outside and held open, so that the panel can read the stage before and after it is released.
promise.jsx
import { Suspense, use, act } from 'react';
import { createRoot } from 'react-dom/client';
let release;
const data = new Promise((resolve) => {
release = () => resolve('ready');
});
function Data() {
const value = use(data);
return <p>data: {value}</p>;
}
const root = createRoot(stage);
const seen = [];
await act(async () =>
root.render(
<Suspense fallback={<span>loading</span>}>
<Data />
</Suspense>,
),
);
seen.push(stage.innerHTML);
await act(async () => release());
seen.push(stage.innerHTML);
console.log(seen);
check('the fallback, then the data', seen, ['<span>loading</span>', '<p>data: ready</p>']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node promise.jsx
[ '<span>loading</span>', '<p>data: ready</p>' ] ok the fallback, then the data
use can sit after a condition
Hooks must be called in the same order on every render (lesson 21). use is not a
hook, and the rule does not apply: you can call it after an early return, inside an
if or a loop. When the condition is false the component never touches the promise, and
so never waits for it. (When a boundary that is already showing something suspends again, React keeps
the old content in the page but hides it, so the panel reads only what is visible.)
conditional.jsx
import { Suspense, use, act } from 'react';
import { createRoot } from 'react-dom/client';
let release;
const data = new Promise((resolve) => {
release = () => resolve('ready');
});
function Maybe({ show }) {
if (!show) return <p>hidden</p>;
const value = use(data);
return <p>shown: {value}</p>;
}
function App({ show }) {
return (
<Suspense fallback={<p>loading</p>}>
<Maybe show={show} />
</Suspense>
);
}
const visible = () =>
Array.from(stage.querySelectorAll('p'))
.filter((p) => p.style.display !== 'none')
.map((p) => p.textContent)
.join('');
const root = createRoot(stage);
const seen = [];
await act(async () => root.render(<App show={false} />));
seen.push(visible());
await act(async () => root.render(<App show={true} />));
seen.push(visible());
await act(async () => release());
seen.push(visible());
console.log(seen);
check('hidden: no waiting', seen[0], 'hidden');
check('shown: suspended on the promise', seen[1], 'loading');
check('after release: the value', seen[2], 'shown: ready');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node conditional.jsx
[ 'hidden', 'loading', 'shown: ready' ] ok hidden: no waiting ok shown: suspended on the promise ok after release: the value
Where does the promise come from, if not the component? From the parent, as a prop; from a cache module that hands out the same promise for the same key; or, in a framework with server components, from the server. Lessons 30 to 33 return to that last one.
What "after the promise settles" means
React resumes the component when the promise's own callbacks run, and those run as microtasks: after the current code finishes, before any timer. Knowing the order of the three ways to schedule work saves a lot of confusion about when things happen. This panel is plain JavaScript, with no React in it, so the order is what Node does. The Node.js course covers it in the lessons on promises and async/await.
order.mjs
const order = [];
setTimeout(() => order.push('setTimeout'), 0);
queueMicrotask(() => order.push('queueMicrotask'));
Promise.resolve().then(() => order.push('then'));
order.push('sync');
await new Promise((resolve) => setTimeout(resolve, 20));
console.log(order);
check('synchronous code, then microtasks in the order queued, then the timer', order, ['sync', 'queueMicrotask', 'then', 'setTimeout']);
What node printed (React 19.2.7, development build), when the build ran it
node order.mjs
[ 'sync', 'queueMicrotask', 'then', 'setTimeout' ] ok synchronous code, then microtasks in the order queued, then the timer
use(promise)reads a promise during render and suspends while it is pending.- The promise must be made outside the component, or cached, so it is the same every render.
usemay be called after a condition or an early return; hooks may not.- React resumes on the promise's microtask, which runs before any timer.
27 Error boundaries
When a component throws while React is rendering it, React cannot show a half-built screen, so by default it removes the whole tree. An error boundary is a component that catches the error from below it and shows something else in that place, a message instead of a blank page, while the rest of the app carries on.
A boundary is a class
This is the only class component in the course, and you will write it once. A class becomes a
boundary by defining static getDerivedStateFromError(error), which returns the new
state (usually the error itself), and optionally componentDidCatch(error, info), for
logging. Its render shows the fallback when the state holds an error.
React also reports every error a boundary catches to the root, through the
onCaughtError option of createRoot. The panel uses it to collect the
error, which is also how an app would send it to a log service, and which keeps the console quiet.
Everything outside the boundary, here the heading, stays on the page.
boundary.jsx
import { Component, act } from 'react';
import { createRoot } from 'react-dom/client';
const log = [];
const caught = [];
class Boundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
log.push('getDerivedStateFromError');
return { error };
}
componentDidCatch(error, info) {
log.push('componentDidCatch');
}
render() {
return this.state.error ? <p>Something went wrong.</p> : this.props.children;
}
}
function Profile({ user }) {
return <p>{user.name}</p>;
}
const root = createRoot(stage, {
onCaughtError: (error) => {
log.push('onCaughtError');
caught.push(error);
},
});
await act(async () =>
root.render(
<main>
<h1>page</h1>
<Boundary>
<Profile user={null} />
</Boundary>
</main>,
),
);
console.log(stage.innerHTML);
console.log(log);
check('the fallback replaced only the broken part', stage.innerHTML, '<main><h1>page</h1><p>Something went wrong.</p></main>');
check('onCaughtError got one error', caught.length, 1);
check('and it was a TypeError', caught[0] instanceof TypeError, true);
check('getDerivedStateFromError came first', log[0], 'getDerivedStateFromError');
check('then onCaughtError, then componentDidCatch', log.filter((name) => name !== 'getDerivedStateFromError'), ['onCaughtError', 'componentDidCatch']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node boundary.jsx
<main><h1>page</h1><p>Something went wrong.</p></main> [ 'getDerivedStateFromError', 'getDerivedStateFromError', 'onCaughtError', 'componentDidCatch' ] ok the fallback replaced only the broken part ok onCaughtError got one error ok and it was a TypeError ok getDerivedStateFromError came first ok then onCaughtError, then componentDidCatch
What a boundary does not catch
A boundary catches errors thrown while a component below it renders. It does not catch an
error in an event handler.
A handler does not run while React is drawing the screen, so there is no half-built tree to rescue;
the error is an ordinary one, reported to the browser like any other thrown error. In the panel it
arrives as an error event on window, which the panel listens for and
silences. The button is still there, and the fallback never appeared.
handler.jsx
import { Component, act } from 'react';
import { createRoot } from 'react-dom/client';
class Boundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
render() {
return this.state.error ? <p>Something went wrong.</p> : this.props.children;
}
}
const reported = [];
window.addEventListener('error', (event) => {
event.preventDefault();
reported.push(event.error.message);
});
function Risky() {
return (
<button
onClick={() => {
throw new Error('boom in a handler');
}}
>
go
</button>
);
}
const root = createRoot(stage);
await act(async () =>
root.render(
<Boundary>
<Risky />
</Boundary>,
),
);
await act(async () => stage.querySelector('button').click());
console.log(stage.innerHTML, reported);
check('the boundary did not show its fallback', stage.innerHTML, '<button>go</button>');
check('the error went to window instead', reported, ['boom in a handler']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node handler.jsx
<button>go</button> [ 'boom in a handler' ] ok the boundary did not show its fallback ok the error went to window instead
Handle those yourself, with try and catch in the handler, and put the
outcome in state. Then the error is shown by an ordinary render:
try-catch.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Risky() {
const [problem, setProblem] = useState('');
const go = () => {
try {
throw new Error('boom in a handler');
} catch (e) {
setProblem(e.message);
}
};
return (
<div>
<button onClick={go}>go</button>
<p>{problem || 'fine'}</p>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Risky />));
const before = stage.querySelector('p').textContent;
await act(async () => stage.querySelector('button').click());
const after = stage.querySelector('p').textContent;
console.log(before, after);
check('before the click', before, 'fine');
check('after the click the page says what happened', after, 'boom in a handler');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node try-catch.jsx
fine boom in a handler ok before the click ok after the click the page says what happened
The default: React logs it
With no onCaughtError, React uses its default, which writes the error to the console
so that you can find it. The fallback is the same; the difference is the console record. This panel
declares what the record contains: the error itself, then which component it came from, then what
React will do about it. The lines are copied from the run.
default.jsx
import { Component, act } from 'react';
import { createRoot } from 'react-dom/client';
class Boundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
render() {
return this.state.error ? <p>Something went wrong.</p> : this.props.children;
}
}
function Profile({ user }) {
return <p>{user.name}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Boundary><Profile user={null} /></Boundary>));
console.log(stage.innerHTML);
check('the fallback is shown', stage.textContent, 'Something went wrong.');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node default.jsx
TypeError: Cannot read properties of null (reading 'name')
at Profile (/app/default.jsx:15:19)
at Object.react_stack_bottom_frame (/app/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20)
at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22)
at updateFunctionComponent (/app/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19)
at beginWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18)
at runWithFiberInDEV (/app/node_modules/react-dom/cjs/react-dom-client.development.js:874:13)
at performUnitOfWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22)
at workLoopSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41)
at renderRootSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11)
at performWorkOnRoot (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35) {
[stack]: [Getter/Setter],
[message]: "Cannot read properties of null (reading 'name')"
}
The above error occurred in the <Profile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, Boundary.
<p>Something went wrong.</p>
ok the fallback is shown
Read the record the way lesson 3 taught: the first line is what went wrong, the next names the
component, and the third says what React did. "Using the error boundary you provided" is the sign
that something caught it. See the errors lesson in the Node.js course for
what an Error is.
- An error boundary is a class with
getDerivedStateFromError, and optionallycomponentDidCatch. - It catches errors in rendering below it and shows a fallback; the rest of the page stays.
onCaughtErroroncreateRootcollects what boundaries catch.- Errors in event handlers are not caught by a boundary; use
tryandcatch. - With the default handler, React logs the error, the component and the boundary to the console.