Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Error boundaries in React

Turtlekit Editorial Team

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.

InvoicePage<h1>Send reminderSectionBoundaryPaymentTimelinethrows
The timeline’s error travels up to the nearest boundary and no further. The heading and the reminder button are outside it, so they never learn it happened.

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.

Rendering partial data in React

When the failure is expected, and the section should say so without throwing.

Loading components on demand in React

When the part that fails is code that couldn’t be downloaded.

Guard clauses and early returns in React

When a case you can name should return early instead of throwing.

Optimistic UI updates with useOptimistic

When an Action fails and the screen should roll back with a message.