open atlas
↑ Back to track
React patterns, senior RXP · 11 · 02

Error boundaries

Suspense handles the loading state; an Error Boundary above it handles the failed state by catching errors thrown during render and showing a fallback — a real async UI needs both, and a Suspense with no boundary lets a failed fetch crash to a blank screen.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You learned to place Suspense boundaries close to the data so a page reveals progressively. So you wrap your async feed in <Suspense>, give it a skeleton, and it streams in beautifully. Then the recommendation service times out — and the page goes white. Blank. No skeleton, no error, no recovery. Just an empty screen and a stack trace in the console that the user will never see.

Suspense answered one of the two questions an async region asks — what shows while it’s loading? — and silently ignored the other: what shows when it fails? A <Suspense> with no Error Boundary above it has no answer for failure, so a thrown error keeps bubbling up until it unmounts your whole tree. Every Suspense boundary needs an Error Boundary above it. This lesson is that pairing.

Goal

After this lesson you can explain that an Error Boundary catches errors thrown during render (and from a Suspense child whose data fetch rejected) and shows a fallback instead of crashing; pair an Error Boundary above each Suspense so loading and failure are both handled; wire a reset so the user can retry without a full reload; and name the sharp edge — errors thrown in event handlers and effects are not caught by Error Boundaries and must be handled separately.

1

An Error Boundary catches errors thrown during render and shows a fallback instead of unmounting the tree. It is a component that wraps a subtree: if any descendant throws while rendering, the boundary swaps that subtree for its fallback UI. As of React 19 there is still no Hook form — an error boundary is a class component implementing getDerivedStateFromError (to render the fallback) and optionally componentDidCatch (to log). The react-error-boundary package is the standard wrapper most teams use instead of hand-rolling it.

import { Component, type ReactNode } from "react";

class ErrorBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };   // render the fallback on the next render
  }
  componentDidCatch(error: Error) {
    reportToSentry(error);       // side-effect: log it
  }
  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

Without a boundary, a render-time throw propagates up the tree and React unmounts everything from the nearest boundary upward — and if there’s no boundary at all, the entire root. That unmount is the blank white screen.

2

Suspense handles loading; an Error Boundary handles failed — a real async UI needs both, stacked. A pending data fetch suspends (Suspense catches it and shows the fallback). A rejected data fetch throws (the Error Boundary catches it and shows its fallback). They are two different signals for two different states, caught by two different boundaries. The Error Boundary goes above the Suspense, because a failure should replace the whole loading region — including its skeleton — not render inside it.

// The pairing. Outer = failed state, inner = loading state.
<ErrorBoundary fallback={<FeedError />}>
  <Suspense fallback={<FeedSkeleton />}>
    <ActivityFeed />   {/* pending → skeleton; rejected → FeedError */}
  </Suspense>
</ErrorBoundary>

Read it top-down: while ActivityFeed’s data is pending, the user sees FeedSkeleton; if it resolves, they see the feed; if it rejects, the throw escapes Suspense, hits the Error Boundary, and they see FeedError. Three states, fully handled.

3

Give the boundary a reset so the user can retry the failed region without reloading the page. A catch-and-show-fallback that has no way back is a dead end. react-error-boundary exposes a resetErrorBoundary() callback to your fallback; calling it clears the error state and re-renders the children, which re-runs the suspended fetch. Pair it with a resetKeys array so the boundary also auto-resets when an input (like the user id) changes.

import { ErrorBoundary } from "react-error-boundary";

function FeedSection({ userId }: { userId: string }) {
  return (
    <ErrorBoundary
      FallbackComponent={FeedError}
      resetKeys={[userId]}         // auto-reset when userId changes
    >
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

function FeedError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
  return (
    <div role="alert">
      <p>Couldn't load the feed.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

Now a transient failure is recoverable in place: the user clicks Retry, the region drops back into its FeedSkeleton, and the fetch runs again — no full-page reload, no losing the rest of the screen.

4

The sharp edge: Error Boundaries only catch render-phase errors. Errors in event handlers, effects, timeouts, and async callbacks are NOT caught. This is the failure mode that surprises people. getDerivedStateFromError runs during rendering, so it only sees throws that happen during render (including a Suspense child whose read rejected). A throw inside an onClick, a setTimeout, a fetch().then(...), or the body of a useEffect happens outside render — React doesn’t route it to the boundary, and it either crashes the handler silently or surfaces as an unhandled rejection.

function SaveButton() {
  // ❌ NOT caught by any Error Boundary — this is a handler, not render.
  async function onSave() {
    await api.save();          // if this rejects, the boundary never sees it
  }
  return <button onClick={onSave}>Save</button>;
}

// ✅ Handle event/effect errors yourself — try/catch + local error state.
function SaveButton() {
  const [err, setErr] = useState<string | null>(null);
  async function onSave() {
    try { await api.save(); }
    catch { setErr("Save failed. Try again."); }
  }
  return (
    <>
      <button onClick={onSave}>Save</button>
      {err && <p role="alert">{err}</p>}
    </>
  );
}

So Error Boundaries are necessary but not sufficient: they cover the render path (data fetching for components that read on render, bad props, a component that throws while rendering), and you still owe a separate strategy — try/catch plus local state, or your data layer’s error handling — for everything that happens in handlers and effects.

Worked example

A dashboard widget: from a Suspense that can blank the page to a fully-guarded region. A <RevenueChart> reads from a flaky analytics endpoint. The first version handles loading but not failure.

Before — Suspense only. When the endpoint 500s, the read rejects, the throw escapes Suspense, and with no boundary in the tree it propagates to the root and unmounts the entire dashboard:

export default function Dashboard() {
  return (
    <DashboardLayout>
      <Header />
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />   {/* rejects → throw bubbles to root → blank page */}
      </Suspense>
    </DashboardLayout>
  );
}
// Loading: fine. Failure: the whole dashboard — header and all — goes white.

After — an Error Boundary above the Suspense, scoped to just this widget, with a retry:

import { ErrorBoundary } from "react-error-boundary";

export default function Dashboard() {
  return (
    <DashboardLayout>
      <Header />
      <ErrorBoundary FallbackComponent={ChartError}>
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
      </ErrorBoundary>
    </DashboardLayout>
  );
}

function ChartError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
  return (
    <div role="alert" className="chart-error">
      <p>Revenue data is unavailable right now.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

Now the failure is contained: the header, layout, and every other widget keep rendering; only the chart’s slot shows an inline error with a working Retry. Same Suspense, same data, same flaky endpoint — but a failed fetch is now a recoverable inline message instead of a blank screen. The boundary is scoped tightly (one widget) on purpose: a single Error Boundary at the route root would replace the whole dashboard on any one widget’s failure, which is exactly the all-or-nothing problem placement taught you to avoid — applied to errors instead of loading.

Why this works

Why must the Error Boundary sit above the Suspense rather than beside or below it? Because the error originates from the same child that suspends — the component that reads data on render. When its read rejects, the throw travels up the tree looking for the nearest boundary, passing straight through Suspense (Suspense only intercepts the pending signal, not throws). The first Error Boundary it meets on the way up catches it. If the Error Boundary were inside the Suspense, the throw would have already left the Suspense subtree before reaching it. Above-the-Suspense is the only position that catches a failure from the region the Suspense is guarding — which is why the canonical shape is always ErrorBoundary > Suspense > async child.

Common mistake

The most expensive mistake is treating “I added Suspense” as “I handled the async region.” Suspense is half the contract. The reflex to build is: every time you draw a Suspense boundary, draw an Error Boundary above it in the same edit — make the pair a single mental unit. The second trap is putting one Error Boundary at the app root and calling it done: it technically stops the blank screen, but now any failure anywhere replaces your entire app with one generic error page, throwing away every other region that rendered fine. Scope error boundaries to the same independent regions you scoped Suspense to, so one widget’s failure costs you one widget — not the whole screen.

Check yourself
Quiz

A component wraps an async <UserList> (reads data on render) in a <Suspense fallback={<Skeleton/>}> with no Error Boundary. There's also a 'Refresh' button whose onClick calls await refetch(). The fetch endpoint starts returning 500s for both the initial render and the refresh. What happens, and what's the senior fix?

Recap

An async region has three states, and Suspense alone covers only one. Suspense handles loading (a pending read suspends → show the skeleton). An Error Boundary handles failed (a rejected read throws during render → show an error fallback). They stack, with the Error Boundary above the Suspense, because a failure should replace the whole loading region. In React 19 an error boundary is still a class (getDerivedStateFromError + componentDidCatch), and react-error-boundary is the standard wrapper — give its fallback a resetErrorBoundary() (and resetKeys) so users can retry in place instead of reloading. The failure mode this lesson kills: a Suspense with no Error Boundary, where a failed fetch bubbles past the root and blanks the screen. And the sharp edge: Error Boundaries catch only render-phase errors — event-handler and effect errors are not caught and need their own try/catch plus local state. Scope error boundaries to the same independent regions as your Suspense boundaries, so one widget’s failure costs one widget, not the whole app. Every Suspense boundary needs an Error Boundary above it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.