Emmanuel Akinfulubi

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.

The fallback, then the component

ran at build time · in happy-dom 20.11.6 · React dev

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.

Two boundaries, two waits

ran at build time · in happy-dom 20.11.6 · React dev

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.

A boundary with nothing to wait for

ran at build time · server (no DOM) · React dev

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 Suspense above a suspended component shows its fallback.
  • 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.

use(promise) under Suspense

ran at build time · in happy-dom 20.11.6 · React dev

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

use after a condition

ran at build time · in happy-dom 20.11.6 · React dev

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.

Microtasks before timers

ran at build time · server (no DOM) · React dev

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.
  • use may 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.

A class boundary, collected by onCaughtError

ran at build time · in happy-dom 20.11.6 · React dev

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.

An error in an event handler

ran at build time · in happy-dom 20.11.6 · React dev

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 and catch in the handler

ran at build time · in happy-dom 20.11.6 · React dev

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.

The default handler writes to the console

warns, on purpose · in happy-dom 20.11.6 · React dev

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 optionally componentDidCatch.
  • It catches errors in rendering below it and shows a fallback; the rest of the page stays.
  • onCaughtError on createRoot collects what boundaries catch.
  • Errors in event handlers are not caught by a boundary; use try and catch.
  • With the default handler, React logs the error, the component and the boundary to the console.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.