The account manager was on a call with Bluebell Bakery when she switched the customer page from Northwind to Bluebell. Bluebell’s invoices appeared, and a moment later Northwind’s replaced them under Bluebell’s name, one of them overdue. She had read it out before anyone noticed.
Nothing was wrong with the server. Northwind’s request had been slower than Bluebell’s, and the copy of the loading code on that page had lost its guard against late answers. The newest answer won the render, even though it answered the older question.
React 19 lets a component read a promise directly with use. While the promise is pending the component suspends, and the nearest Suspense boundary shows its fallback; if it rejects, the nearest error boundary takes over. Start the request where the decision is made, pass the promise down, and the loading flags, the error flag and the race guard leave the component.
Ten lines that every reader repeats
This is the history as the page first had it, written with care:
// Avoid: the request waits for a render, and each reader keeps the
// flags
export function InvoiceHistory({
customer,
loadInvoices,
}: InvoiceHistoryProps) {
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [invoices, setInvoices] = useState<Invoice[]>([]);
useEffect(() => {
let ignore = false;
setLoading(true);
setFailed(false);
loadInvoices(customer.id)
.then((rows) => !ignore && setInvoices(rows))
.catch(() => !ignore && setFailed(true))
.finally(() => !ignore && setLoading(false));
return () => {
ignore = true;
};
}, [customer.id, loadInvoices]);
if (loading) return <p>Loading invoices…</p>;
if (failed) return <p>We couldn’t load the invoices.</p>;
return (
<InvoiceTable customer={customer.name} invoices={invoices} />
);
}It works. Read it again and count what it asks of the component: three pieces of state, a request started only after the first render has reached the screen, and an ignore flag whose whole job is to throw away answers nobody wants any more. Every component that loads its own data carries the same ten lines, and each copy has to remember the guard.
The copy on the customer page didn’t. Remove the ignore checks and the two requests race: whichever answer arrives last is set into state, whichever customer it belongs to. The flags have a quieter fault too. loading, failed and invoices are separate, so nothing stops a render that shows the last customer’s rows under the new name, before the new request has even started.
Pass the request, not its result
A promise is the request itself, held as a value. It can be created anywhere, stored in state and passed as a prop, and it already knows whether it is pending, resolved or rejected. The component that shows the invoices doesn’t need to track any of that. It needs to read the promise, and to trust someone else with the moments before and after.
use is the reader. It returns the resolved value, or suspends the component until there is one. Suspense owns the waiting, and an error boundary owns the failure. Start the request where the decision is made; read it where the data is shown.
Moving the request out of the component
Three changes turn the history into a reader: it takes a promise, the page starts the request in the event that needs it, and two boundaries take the waiting and the failure.
Read the promise with use()
export function InvoiceHistory({
customer,
invoices,
}: {
customer: Customer;
invoices: Promise<Invoice[]>;
}) {
// Suspends until the promise settles; a rejection goes to the
// boundary.
const rows = use(invoices);
return <InvoiceTable customer={customer.name} invoices={rows} />;
}That is the whole component. There is no state, no effect and no branch for loading or failure: when use returns, the rows are there. Unlike a Hook, use may be called inside a condition or a loop, but it can’t sit inside a try block, because it suspends by throwing.
Start the request in the event
The page keeps the chosen customer and that customer’s request together, in one piece of state. The route started the first request before the page rendered; each later one starts in the change handler:
const [selected, setSelected] = useState(initial);
const { customer, invoices } = selected;
// The request starts in the event that needs it, not after a
// render.
function load(next: Customer) {
setSelected({ customer: next, invoices: loadInvoices(next.id) });
}
function choose(event: ChangeEvent<HTMLSelectElement>) {
const next = customers.find((c) => c.id === event.target.value);
if (next) load(next);
}Because the customer and the promise are set together, they can’t disagree. A slow answer for Northwind still arrives, but no component reads that promise any more, so it lands nowhere.
Pitfall
A promise created during render is a new promise on every render. React shows the fallback again each time and warns that a component was suspended by an uncached promise. Many reach for useMemo to hold it, but React may discard a memoised value, and a render that suspends before it mounts keeps no state at all. Create the promise in an event handler, a route loader or a Server Component, or read it from a cache that returns the same promise for the same request, as react.dev’s reference for use recommends.
Give the waiting and the failure a place
React has no error boundary as a function component, so the page brings a small class of its own. It shows its fallback when a render below it throws, and a promise read with use that rejects counts as one:
// One section's error boundary: its fallback replaces the section
// when a render below throws, or a promise read below rejects.
import { Component } from "react";
import type { ReactNode } from "react";
interface SectionBoundaryProps {
children: ReactNode;
fallback: ReactNode;
/** A new value clears the error and renders the children again. */
resetKey: unknown;
}
export class SectionBoundary extends Component<
SectionBoundaryProps,
{ failed: boolean }
> {
override state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
override componentDidUpdate(previous: SectionBoundaryProps) {
if (
this.state.failed &&
previous.resetKey !== this.props.resetKey
)
this.setState({ failed: false });
}
override render() {
return this.state.failed
? this.props.fallback
: this.props.children;
}
}Its resetKey is the promise. A new request, from the picker or from Try again, is a new key, so the boundary renders the history again instead of keeping the old failure. Around the history, the page places both boundaries:
const heading = useRef<HTMLHeadingElement>(null);
// Try again goes away with the failure, so focus moves to the
// heading.
function retry() {
load(customer);
heading.current?.focus();
}
const failed = (
<div role="alert">
<p>We couldn’t load the invoices for {customer.name}.</p>
<button type="button" onClick={retry}>
Try again
</button>
</div>
);
return (
<section aria-labelledby="customer-heading">
<h2 id="customer-heading" ref={heading} tabIndex={-1}>
{customer.name}
</h2>
<label htmlFor="customer">Customer</label>
<select id="customer" value={customer.id} onChange={choose}>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* A new request clears an earlier failure. */}
<SectionBoundary resetKey={invoices} fallback={failed}>
<Suspense
fallback={<p>Loading invoices for {customer.name}…</p>}
>
<InvoiceHistory customer={customer} invoices={invoices} />
</Suspense>
</SectionBoundary>
</section>
);The finished page
// The customer page: the request starts when someone picks a
// customer, and the history reads it with use() inside Suspense and
// an error boundary.
import { Suspense, use, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import { InvoiceTable } from "./invoice-table";
import type { Customer, Invoice, LoadInvoices } from "./invoices";
import { SectionBoundary } from "./section-boundary";
export function InvoiceHistory({
customer,
invoices,
}: {
customer: Customer;
invoices: Promise<Invoice[]>;
}) {
// Suspends until the promise settles; a rejection goes to the
// boundary.
const rows = use(invoices);
return <InvoiceTable customer={customer.name} invoices={rows} />;
}
interface Selection {
customer: Customer;
invoices: Promise<Invoice[]>;
}
interface CustomerPageProps {
customers: Customer[];
loadInvoices: LoadInvoices;
/** Started by the route, before the page first rendered. */
initial: Selection;
}
export function CustomerPage({
customers,
loadInvoices,
initial,
}: CustomerPageProps) {
const [selected, setSelected] = useState(initial);
const { customer, invoices } = selected;
// The request starts in the event that needs it, not after a
// render.
function load(next: Customer) {
setSelected({ customer: next, invoices: loadInvoices(next.id) });
}
function choose(event: ChangeEvent<HTMLSelectElement>) {
const next = customers.find((c) => c.id === event.target.value);
if (next) load(next);
}
const heading = useRef<HTMLHeadingElement>(null);
// Try again goes away with the failure, so focus moves to the
// heading.
function retry() {
load(customer);
heading.current?.focus();
}
const failed = (
<div role="alert">
<p>We couldn’t load the invoices for {customer.name}.</p>
<button type="button" onClick={retry}>
Try again
</button>
</div>
);
return (
<section aria-labelledby="customer-heading">
<h2 id="customer-heading" ref={heading} tabIndex={-1}>
{customer.name}
</h2>
<label htmlFor="customer">Customer</label>
<select id="customer" value={customer.id} onChange={choose}>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* A new request clears an earlier failure. */}
<SectionBoundary resetKey={invoices} fallback={failed}>
<Suspense
fallback={<p>Loading invoices for {customer.name}…</p>}
>
<InvoiceHistory customer={customer} invoices={invoices} />
</Suspense>
</SectionBoundary>
</section>
);
}Open the page and “Loading invoices for Northwind Ltd…” stands where the table will be. The table replaces it when the request resolves, its caption naming the customer. Pick Bluebell and the line returns with Bluebell’s name, and Bluebell’s rows follow, however late Northwind’s answer comes. If the request fails, an alert names the customer, and Try again sends it once more.
What changes when components stop fetching
Moving the request out of the component moves some decisions with it. Most of them get simpler; one becomes yours.
The race is gone by construction
The guard in the first version existed because state could be set by any answer. Here, the only answer that can render is the one for the promise in state. There is nothing to cancel and nothing to compare. If the server should stop work it no longer needs, abort the request with an AbortController in the handler; that saves the server time, not the screen.
Caching is now yours
Every pick starts a new request, so switching back to Northwind asks for its invoices again. For this page that is right: invoices change. If repeat visits should be instant, put a cache between the page and the server that returns the same promise for the same customer, and decide when it forgets. That is the work data libraries and frameworks do, and the reason use leaves it to them.
Waiting and failing, in words
The fallback is words that name the customer, not a spinner, and the table’s caption names the customer again when it arrives. The failure is an alert with a way forward. Picking a customer leaves focus on the picker; Try again disappears with the failure it answers, so it moves focus to the customer’s heading rather than letting it fall to the top of the page.
Testing the moment before the answer
The test hands the page a server it answers by hand, so it can hold a request open, answer two in the wrong order, or refuse one:
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { CustomerPage } from "./customer-page";
import type { Customer, Invoice } from "./invoices";
const NORTHWIND: Customer = { id: "cus_1", name: "Northwind Ltd" };
const BLUEBELL: Customer = { id: "cus_2", name: "Bluebell Bakery" };
const invoice = (number: string): Invoice => ({
id: number,
number,
total: "£480.00",
status: "Open",
});
/** A server the test answers when it chooses, request by request. */
function heldServer() {
const requests: {
id: string;
resolve: (rows: Invoice[]) => void;
reject: () => void;
}[] = [];
const loadInvoices = vi.fn(
(id: string) =>
new Promise<Invoice[]>((resolve, reject) => {
requests.push({
id,
resolve,
reject: () => reject(new Error("Network")),
});
}),
);
return { loadInvoices, requests };
}
async function renderPage() {
const server = heldServer();
const initial = {
customer: NORTHWIND,
invoices: server.loadInvoices(NORTHWIND.id),
};
// The page suspends as it mounts, so it renders inside an awaited
// act(): a synchronous render would leave React's retry queued and
// never run.
await act(async () => {
render(
<CustomerPage
customers={[NORTHWIND, BLUEBELL]}
loadInvoices={server.loadInvoices}
initial={initial}
/>,
);
});
return server;
}
test("the fallback shows, then the invoices", async () => {
const server = await renderPage();
expect(
screen.getByText("Loading invoices for Northwind Ltd…"),
).toBeVisible();
await act(async () =>
server.requests[0]?.resolve([invoice("INV-2041")]),
);
// React reveals a boundary's content in batches, so wait for the
// table.
expect(
await screen.findByRole("table", {
name: "Invoices for Northwind Ltd",
}),
).toBeVisible();
expect(
screen.getByRole("rowheader", { name: "INV-2041" }),
).toBeVisible();
});
test("a late answer never shows under a new customer", async () => {
const server = await renderPage();
const picker = screen.getByRole("combobox", { name: "Customer" });
// A change that suspends runs in an awaited act(), so React's retry
// runs.
await act(() => userEvent.selectOptions(picker, BLUEBELL.id));
expect(server.loadInvoices).toHaveBeenLastCalledWith(BLUEBELL.id);
await act(async () =>
server.requests[1]?.resolve([invoice("INV-3107")]),
);
await act(async () =>
server.requests[0]?.resolve([invoice("INV-2041")]),
);
expect(
await screen.findByRole("table", {
name: "Invoices for Bluebell Bakery",
}),
).toBeVisible();
expect(
screen.getByRole("rowheader", { name: "INV-3107" }),
).toBeVisible();
expect(screen.queryByText("INV-2041")).toBeNull();
});
test("failure shows the boundary; Try again asks again", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const server = await renderPage();
await act(async () => server.requests[0]?.reject());
expect(await screen.findByRole("alert")).toHaveTextContent(
"We couldn’t load the invoices for Northwind Ltd.",
);
await act(() =>
userEvent.click(
screen.getByRole("button", { name: "Try again" }),
),
);
expect(server.loadInvoices).toHaveBeenCalledTimes(2);
expect(
screen.getByRole("heading", { name: "Northwind Ltd" }),
).toHaveFocus();
expect(
screen.getByText("Loading invoices for Northwind Ltd…"),
).toBeVisible();
await act(async () =>
server.requests[1]?.resolve([invoice("INV-2041")]),
);
expect(
await screen.findByRole("rowheader", { name: "INV-2041" }),
).toBeVisible();
expect(screen.queryByRole("alert")).toBeNull();
vi.mocked(console.error).mockRestore();
});All three pass. The second replays the opening: Bluebell’s answer arrives, then Northwind’s, and Northwind’s rows never render.
Server Components and streaming
In a Server Component, the simplest choice is usually to await the data where it is fetched. When the shell should reach the browser first, start the request in the Server Component and pass the promise, unawaited, to a Client Component that reads it with use. React streams the value when it resolves, so it has to be serialisable: rows of strings and numbers, not class instances.
React 18, 19 and the Compiler
use arrived in React 19. On React 18, the first version is the honest way to fetch without a library, guard included. The React Compiler memoises values for speed, not for identity across suspensions, so it doesn’t make a promise created in render safe to read.
When the data needs more than one read
A promise settles once. Data that keeps changing after it arrives, such as a live balance or a value in another tab, is a subscription; read it with useSyncExternalStore. Data that many screens share, refetch in the background and invalidate after a change belongs in a data library, which can hand you promises to use or hooks of its own.
And a request that only one component needs, started by nothing but that component appearing, is still a fine effect on React 18. Write it with its guard, and keep it in one custom hook so the guard is written once.
How Turtlekit’s ErrorBoundary and skeletons hold the section
With Turtlekit, the hand-written boundary and the loading line give way to the kit’s ErrorBoundary and Skeleton. resetKeys does the job of the page’s resetKey, and the fallback’s Try again starts a new request:
// The invoice history with Turtlekit: the kit's ErrorBoundary and
// skeleton take the place of the hand-written boundary and the
// loading line.
import { Suspense } from "react";
import {
ErrorBoundary,
ErrorFallback,
SkeletonText,
Stack,
Text,
} from "turtlekit";
import { InvoiceHistory } from "./customer-page";
import type { Customer, Invoice } from "./invoices";
interface InvoiceSectionProps {
customer: Customer;
invoices: Promise<Invoice[]>;
/** Starts a new request for the same customer. */
retry: () => void;
}
export function InvoiceSection({
customer,
invoices,
retry,
}: InvoiceSectionProps) {
return (
<ErrorBoundary
resetKeys={[invoices]}
fallback={({ error }) => (
// Try again starts a new request; the new promise resets the
// boundary.
<ErrorFallback
error={error}
reset={retry}
title={`We couldn’t load the invoices for ${customer.name}`}
description="Check the connection, then try again."
/>
)}
>
<Suspense
fallback={
<Stack gap="tight" aria-busy="true">
<Text as="p" size="2" tone="muted">
Loading invoices for {customer.name}…
</Text>
<SkeletonText lines={3} />
</Stack>
}
>
<InvoiceHistory customer={customer} invoices={invoices} />
</Suspense>
</ErrorBoundary>
);
}The history itself doesn’t change: it still reads one promise with use. The kit’s fallback is a danger Callout that says what failed and holds the retry, and the skeleton holds a place for the rows, so the page shifts less when they arrive.
When a promise won’t settle on screen
The fallback never goes away
The promise is created during render, so each render suspends on a new one. React’s warning that a component “was suspended by an uncached promise” confirms it. Start the request in an event or a loader, or read it from a cache.
“Suspense Exception: This is not a real error!”
use is inside a try block. It suspends by throwing, so the catch intercepts React’s signal. Remove the try and let an error boundary handle rejections.
Every switch blanks the table
That is Suspense doing what it was told: a new request, not marked as a Transition, shows the fallback. To keep the old invoices on screen until the new ones arrive, keep the picker’s value in its own urgent state and set the selection inside startTransition, the way useTransition and Actions in React splits the period from the report.
Let the promise carry the waiting
The first version asked one component to fetch, wait, fail, and police late answers, and the bug lived in the one copy that forgot the policing. The finished page gives each job to the part that owns it: the change handler starts the request, the promise carries its state, Suspense shows the wait and the boundary shows the failure. What is left in the history is the one thing it was for, the rows, and the only rows it can ever show are the ones it was asked for.