The accountant was on the phone with a client, about to send a payment reminder, when she pressed “Show all 4 payments” on the invoice. The page went white. The invoice, its total and the reminder button all vanished, and the only way back was a reload, while the client waited on the line.
The oldest payment was a bank transfer from the previous billing system, imported without card details. The timeline read the card’s brand from it and threw. With nothing to catch an error thrown while rendering, React removes the UI from the screen, so one line in one section cost the whole page.
An error boundary keeps that cost inside the section. It is a component that catches errors thrown while its children render and shows a fallback in their place. The timeline can then fail while the invoice, its total and its reminder button stay where they are.
One section, the whole page
The page placed the timeline like any other part:
return (
<main>
<h1>Invoice {invoice.number}</h1>
<p>
{invoice.client}, {invoice.total}, due {invoice.due}
</p>
<button type="button" onClick={onRemind}>
Send reminder
</button>
<h2>Payment timeline</h2>
{/* Avoid: an error here takes the whole page with it */}
<PaymentTimeline payments={payments} />
</main>
);And the timeline drew each payment like this:
<li key={payment.id}>
{payment.date}: {payment.amount}, {payment.card.brand}
{` ending ${payment.card.last4}`}
</li>The type said every payment has a card, because the API promises one. The imported transfers were written before that promise existed. Types describe what the code expects, not what years of data contain, and well-tested code still meets values it was never shown. React’s reference on error boundaries puts the default plainly: without one, an error during rendering removes the app’s UI.
Bulkheads for a page
A ship’s hull is divided by bulkheads, so a breach floods one compartment instead of the whole ship. An error boundary is a bulkhead in the component tree. An error thrown below it travels up to it and stops, and everything outside carries on.
Put a boundary where the page should keep working without what is inside it. The invoice is useful without its timeline, so the timeline gets a boundary of its own. The invoice itself is the page, and fails to the boundary above it.
A boundary around the timeline
The boundary here draws the section’s frame as well: its heading in every state, then either the timeline or a fallback.
Catching the error
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
// Before the fallback replaces the children: was focus inside?
override getSnapshotBeforeUpdate(): boolean {
const section = this.heading.current?.parentElement;
return Boolean(section?.contains(document.activeElement));
}
override componentDidUpdate(
_: Props,
before: State,
hadFocus: boolean,
) {
// A new failure replaced what held focus: bring focus back here.
const failed =
this.state.error && this.state.error !== before.error;
if (failed && hadFocus) {
this.heading.current?.focus();
}
if (!this.state.error && this.state.retries > 0)
this.setState({ retries: 0 });
}getDerivedStateFromError runs when a component below throws during rendering, and returns the state that shows the fallback. It is a static method of a class component: React 19 still has no function-component equivalent, as its Component reference notes. componentDidUpdate handles focus, which the accessibility notes below explain.
A fallback that says what still works
override render() {
const { id, title, children } = this.props;
const { error, retries } = this.state;
return (
<section aria-labelledby={id}>
<h2 id={id} ref={this.heading} tabIndex={-1}>
{title}
</h2>
{error ? (
<div role="alert">
{retries > 0 ? (
<p>
Trying again didn’t help. The error has been reported;
the rest of the invoice still works.
</p>
) : (
<p>
This section couldn’t be shown. The rest of the
invoice is complete and works as usual.
</p>
)}
<button type="button" onClick={this.retry}>
Try again
</button>
</div>
) : (
children
)}
</section>
);
}The heading stays, so the section is still named and still in the page outline. The message says what failed and, as important, what didn’t: the rest of the invoice is complete and works. It is an alert, so a failure that happens while someone is using the section is read at once, and “Try again” sits inside it.
Trying again
retry = () => {
this.heading.current?.focus(); // the button is about to go
this.setState((state) => ({
error: null,
retries: state.retries + 1,
}));
};Clearing the error renders the children again, from scratch: they were unmounted when the fallback took their place. Here that is a recovery in itself. The timeline starts again with its latest three payments, which render, and only “Show all” would meet the transfer. If the retry fails as well, the message changes to say so. An alert that repeats identical words gives a screen reader nothing new to read, and “Trying again didn’t help” is also the truth.
Pitfall
A boundary catches errors thrown while rendering, in lifecycle methods and in effects. It doesn’t catch errors in event handlers, in asynchronous code such as a setTimeout callback, during server rendering, or in the boundary itself. React 19 adds one case it does catch: an error thrown by a function passed to startTransition, as useTransition’s reference shows. Catch a handler’s failure in the handler, and show it as state.
Placing it
"use client";
// The invoice page. The payment timeline is a section with its own
// boundary, so its failure never takes the invoice or its actions
// with it.
import { PaymentTimeline } from "./payment-timeline";
import { SectionBoundary } from "./section-boundary";
import type { Invoice, Payment } from "./invoice";
interface Props {
invoice: Invoice;
payments: readonly Payment[];
onRemind: () => void;
}
export function InvoicePage({ invoice, payments, onRemind }: Props) {
return (
<main>
<h1>Invoice {invoice.number}</h1>
<p>
{invoice.client}, {invoice.total}, due {invoice.due}
</p>
<button type="button" onClick={onRemind}>
Send reminder
</button>
{/* Keyed by invoice: another invoice starts clean. */}
<SectionBoundary
key={invoice.id}
id="payment-timeline"
title="Payment timeline"
>
<PaymentTimeline payments={payments} />
</SectionBoundary>
</main>
);
}Press “Show all 4 payments” on INV-0042 and the timeline gives way to its message, with focus on the “Payment timeline” heading. The title, the total and “Send reminder” are untouched and work. “Try again” brings back the latest three payments. Open another invoice and its timeline starts clean, because the boundary is keyed by the invoice.
Living with boundaries
A boundary is a small component with consequences for layout, reporting and focus.
Where boundaries go
Around the sections a page can do without: a timeline, a chart, a comments panel, a widget from another team. Add one near the root as the last net, with a page-sized message and a reload. Not around every component: each fallback is a piece of interface someone has to design, and a page scattered with small failure messages is harder to use than one clear one.
Reporting once, at the root
React logs every error to the console by default. React 19 lets the root report them instead, with two options on createRoot: onCaughtError for errors a boundary caught, onUncaughtError for the rest.
// The app's root: every error React meets is reported once, here.
import { createRoot } from "react-dom/client";
import type { ReactNode } from "react";
import { report } from "./report";
export function mount(container: Element, app: ReactNode) {
const root = createRoot(container, {
// A boundary caught it: the page carried on without one section.
onCaughtError: (error, info) =>
report(error, {
componentStack: info.componentStack,
caught: true,
}),
// Nothing caught it: React removed the whole page.
onUncaughtError: (error, info) =>
report(error, {
componentStack: info.componentStack,
caught: false,
}),
});
root.render(app);
}Each gets the error and the component stack, the path through the tree to the component that threw. Reporting at the root keeps the boundaries free of logging; a boundary needs componentDidCatch only for a side effect of its own, such as telling the section’s owners.
Accessibility: focus that stays in the section
In the failure the accountant met, she pressed a button inside the timeline, and the error removed it. A removed element can’t hold focus, so focus falls back to the page itself, and a keyboard user starts again from the top. Just before the fallback replaces the section’s content, the boundary notes whether focus was inside the section; if it was, it moves focus to the section’s heading once the fallback is in. The heading has tabIndex={-1} so a script can focus it without adding it to the tab order.
It asks the question before the change because afterwards the focused control is gone and can’t say where it was. Even a focused heading is replaced: React renders a boundary’s output afresh when it catches, so the heading after a failure is a new element and needs focus again. A section that fails while nobody is in it, on load or in the background, leaves focus alone: nothing was taken from anyone. “Try again” moves focus to the heading before it clears the error, since the button is about to go.
Testing a failure on purpose
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { InvoicePage } from "./invoice-page";
import { SectionBoundary } from "./section-boundary";
import type { Invoice, Payment } from "./invoice";
const INVOICE: Invoice = {
id: "inv_42",
number: "INV-0042",
client: "Brightside Studio",
total: "€2,400.00",
due: "15 Dec 2026",
};
const card = { brand: "Visa", last4: "4242" };
const PAYMENTS = [
{ id: "pay_4", date: "1 Dec 2026", amount: "€600.00", card },
{ id: "pay_3", date: "1 Nov 2026", amount: "€600.00", card },
{ id: "pay_2", date: "1 Oct 2026", amount: "€600.00", card },
// A bank transfer from the old system, imported without a card.
{ id: "pay_1", date: "1 Sep 2026", amount: "€600.00" },
] as Payment[];
// React reports each caught error to the console; the test expects
// them.
beforeEach(
() => void vi.spyOn(console, "error").mockImplementation(() => {}),
);
afterEach(() => vi.restoreAllMocks());
test("a failure stays in its section, with focus", async () => {
const user = userEvent.setup();
render(
<InvoicePage
invoice={INVOICE}
payments={PAYMENTS}
onRemind={() => {}}
/>,
);
// React renders a boundary's output afresh when it catches, so look
// it up each time.
const timeline = () =>
screen.getByRole("region", { name: "Payment timeline" });
expect(within(timeline()).getAllByRole("listitem")).toHaveLength(3);
await user.click(
screen.getByRole("button", { name: "Show all 4 payments" }),
);
expect(within(timeline()).getByRole("alert")).toHaveTextContent(
"This section couldn’t be shown.",
);
// The button went with the timeline: focus moved to the section's
// heading.
expect(
screen.getByRole("heading", {
level: 2,
name: "Payment timeline",
}),
).toHaveFocus();
// The rest of the invoice is still there, and still works.
expect(
screen.getByRole("heading", {
level: 1,
name: "Invoice INV-0042",
}),
).toBeVisible();
expect(
screen.getByRole("button", { name: "Send reminder" }),
).toBeVisible();
// Trying again starts the section afresh: the latest three render.
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(
screen.getByRole("heading", {
level: 2,
name: "Payment timeline",
}),
).toHaveFocus();
expect(within(timeline()).queryByRole("alert")).toBeNull();
expect(within(timeline()).getAllByRole("listitem")).toHaveLength(3);
});
function AlwaysFails(): never {
throw new Error("The timeline service sent a page of HTML");
}
test("on load, focus stays; a failed retry says so", async () => {
const user = userEvent.setup();
render(
<SectionBoundary id="timeline" title="Payment timeline">
<AlwaysFails />
</SectionBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"This section couldn’t be shown.",
);
expect(document.body).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Trying again didn’t help.",
);
expect(
screen.getByRole("heading", { name: "Payment timeline" }),
).toHaveFocus();
});
function FailsWhen({ broken }: { broken: boolean }) {
if (broken) throw new Error("The timeline service timed out");
return <p>Three payments</p>;
}
test("a failure with nobody inside leaves focus alone", () => {
const section = (broken: boolean) => (
<SectionBoundary id="timeline" title="Payment timeline">
<FailsWhen broken={broken} />
</SectionBoundary>
);
const { rerender } = render(section(false));
expect(document.body).toHaveFocus();
rerender(section(true));
expect(screen.getByRole("alert")).toBeVisible();
expect(document.body).toHaveFocus();
});Both pass. React reports each caught error to the console, so the test silences it for the errors it causes on purpose. A component that always throws, typed never, covers the failure on load and the retry that fails again.
Server rendering and Server Components
Boundaries don’t catch errors during server rendering. The Suspense reference describes what happens instead: React doesn’t abort the server render and puts the nearest Suspense fallback in the HTML. It then tries the component again in the browser, where a second failure reaches the nearest error boundary. A section that may fail on the server is best wrapped in both. The boundary holds state, so it is a Client Component; the section it wraps can still be rendered on the server and passed in as its children.
React 19 and the Compiler
Boundaries are still classes in React 19, and they catch more than they did: a rejected promise read with use and a failed Action both reach the nearest one. The React 19 upgrade guide also changed the reporting: errors are no longer thrown again after being caught, so each appears once, caught ones through console.error and uncaught ones through window.reportError. The React Compiler works on function components and hooks; the boundary class is outside its reach, and needs nothing from it.
When a boundary is the wrong net
A boundary is for the unexpected. A request that can fail, a payment the bank declines or a service that is sometimes down, is an expected outcome. It belongs in the component’s own states, with words for each, as rendering partial data and one status instead of many booleans show. Throwing it to a boundary would replace a precise message with a general one.
Validation is the same: a form with a missing field isn’t an error in the code. And the boundary is not the fix. The timeline still can’t draw a transfer; it needs a branch for payments without a card, and the report the root sends is how the team finds that out.
How Turtlekit’s ErrorBoundary fits a section
ErrorBoundary goes inside the section, under its heading, so the heading stays whatever happens below:
"use client";
// The payment timeline with Turtlekit: ErrorBoundary inside the
// section, so the section keeps its heading, and resets when the
// invoice changes.
import { ErrorBoundary, Heading, Stack } from "turtlekit";
import { PaymentTimeline } from "./payment-timeline";
import type { Invoice, Payment } from "./invoice";
const DESCRIPTION =
"The rest of the invoice still works. Try again or reload.";
export function TimelineSection({
invoice,
payments,
}: {
invoice: Invoice;
payments: readonly Payment[];
}) {
return (
<Stack
as="section"
gap="items"
aria-labelledby="payment-timeline"
>
<Heading as="h2" id="payment-timeline">
Payment timeline
</Heading>
<ErrorBoundary
resetKeys={[invoice.id]}
title="The payment timeline couldn’t be shown"
description={DESCRIPTION}
>
<PaymentTimeline payments={payments} />
</ErrorBoundary>
</Stack>
);
}Its default fallback is a danger Callout, announced at once, with the title and description you give and “Try again” inside it. resetKeys clears the error when any of its values change, so another invoice starts clean. onError reports a section’s errors when the root doesn’t, and fallback replaces the default with your own. It keeps focus too: when a failure removes the control that had focus, focus moves to the fallback rather than the top of the page, and a failure while focus is elsewhere leaves it alone.
Built on this pattern in Turtlekit: Error boundary.
When the boundary doesn’t do what you expect
Three symptoms account for most surprises with boundaries.
An error from a click isn’t caught
It was thrown in an event handler, which boundaries don’t see. Catch it in the handler and show it as state, or, for an Action, let it throw from inside startTransition.
“Try again” shows the fallback again at once
The cause is in the data, and the retry renders the same data. Retry with something that changes, fresh data or a section that starts from its first state, and let the second failure say so.
The fallback stays after moving to another record
The boundary kept its error across the change. Key it by the record’s id, or reset it when the id changes.
Decide what a failure may take
Every page eventually meets data its code didn’t expect; the only choice is how much of the page the meeting costs. Without a boundary, the answer was everything, on a call, with a client waiting. The boundary doesn’t make the timeline correct. It makes the failure the size of the section. On the finished page, the accountant reads the total and sends the reminder while the timeline says it couldn’t be shown, and her keyboard is still in the section where she left it. The root has already told the team what to fix.