Week 12: Error Boundaries & Suspense

A single component throwing during render can, by default, take down your entire app — no error boundary means a blank white screen. This week covers containing failure to the part of the tree that actually broke, and Suspense: a declarative way to say "this part of the tree isn't ready yet" that Week 8's TanStack Query can plug directly into.

Module 8 of 13 Week 12 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Contain a rendering error to one part of the tree with an error boundary
  • Show fallback UI for a not-yet-ready subtree with Suspense
  • Combine both around a data-fetching component correctly

1. Why Errors Need Boundaries

A JavaScript error thrown during render — a null reference, a failed .map() on undefined — by default unmounts the entire React tree it occurred in. Without anything catching it, one broken widget on a page can blank the whole application, not just itself.

the problem
function UserBadge({ user }: { user: User | null }) {
  return <span>{user.name}</span>; // throws if user is null -- crashes everything above it too
}

A regular try/catch doesn't help here — it can't catch errors thrown during React's own render process. The tool that can is an error boundary: a component that catches errors thrown by its children during rendering and shows fallback UI instead of letting the crash propagate further up the tree.

2. Building an Error Boundary

Error boundaries are the one place in modern React that still requires a class component — there is currently no hook-based equivalent to componentDidCatch. In practice, almost nobody hand-writes one; the standard approach is the react-error-boundary library, which wraps this class-component requirement behind a normal component API.

terminal
npm install react-error-boundary
App.tsx
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert">
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <UserBadge user={null} />
    </ErrorBoundary>
  );
}

When UserBadge throws, ErrorBoundary catches it and renders ErrorFallback instead — the rest of the app outside the boundary keeps working normally. resetErrorBoundary lets the fallback UI offer a retry, re-rendering the boundary's children fresh.

Where to place boundaries

One boundary around your whole app is a reasonable last resort, but placing smaller boundaries around independent, risky sections (a third-party widget, a chart, anything doing complex data transformation) means one broken feature doesn't take the rest of the page down with it — the same "contain the blast radius" thinking behind Week 7's route-level errorElement.

3. Suspense Basics

Suspense lets a part of the tree declare "I'm not ready yet" and shows a fallback until it is — most commonly used today for code-splitting (next week's topic) and, as this week covers, data fetching.

basic shape
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ProfileSection />
    </Suspense>
  );
}

A component "suspends" by throwing a special kind of promise during render — you won't write that mechanism by hand; it's built into the tools that support Suspense (like React.lazy, or TanStack Query's Suspense-enabled hooks below). React catches that thrown promise, shows the nearest Suspense boundary's fallback, and swaps back to the real content once the promise resolves.

4. Suspense for Data Fetching

TanStack Query has a Suspense-enabled variant of useQuery: useSuspenseQuery. Instead of returning isPending/ data and letting you branch on it (Week 8's pattern), it suspends the component until data is ready — the component's render code can assume the data already exists.

before — Week 8's useQuery
function UserProfile({ userId }: { userId: number }) {
  const { data: user, isPending, isError } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p>Error loading user.</p>;
  return <p>{user.name}</p>;
}
after — useSuspenseQuery
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: number }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return <p>{user.name}</p>; // user is guaranteed to exist here -- no loading branch needed
}
usage — loading state lives at the boundary, not the component
<Suspense fallback={<Skeleton />}>
  <UserProfile userId={5} />
</Suspense>

This shifts loading UI out of every individual data-fetching component and up to wherever you place the Suspense boundary — one boundary can cover several suspending components at once, showing a single unified loading state instead of several independent spinners popping in one at a time as each request resolves.

5. Combining Both, Correctly

useSuspenseQuery handles the loading state by suspending, but it still throws on error rather than returning an isError flag — because there's no error branch to check anymore, the error needs to be caught the same way an error boundary catches any other thrown error. The standard combination:

ProfilePage.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function ProfilePage({ userId }: { userId: number }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<Skeleton />}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

ErrorBoundary wraps Suspense, not the other way around — this way it can catch both a genuine render error and a rejected query (which useSuspenseQuery re-throws as a real error once the underlying promise rejects), while Suspense independently handles the pending state. Together, UserProfile's own code only needs to handle the "success" case — every other outcome is handled declaratively by what wraps it.

You'll see this exact shape again in Week 17

Next.js's App Router builds its entire data-fetching and streaming model on Suspense and error boundaries used almost exactly this way — what you're learning here as a manual pattern becomes largely automatic once a framework wires it up for you.

6. Hands-on Exercise

Hands-on

Convert a data-fetching page to Suspense, with correct error containment

Take a page from an earlier week and rebuild its loading/error handling using this week's declarative approach.

Requirements:

  1. Take Week 8's product detail page (or rebuild a similar one) and convert its data fetching from useQuery to useSuspenseQuery.
  2. Wrap it in an ErrorBoundary + Suspense pair, correctly nested per Section 5, with a skeleton fallback and a fallback component showing the error message plus a "Try again" button using resetErrorBoundary.
  3. Make your simulated fetch function randomly throw for an invalid product ID, and confirm the error boundary — not a blank screen — catches it.
  4. Add a second, independent Suspense boundary around a sibling component (e.g. a "related products" section using its own useSuspenseQuery) and confirm the two load independently — one resolving doesn't wait for the other.
Hint

react-error-boundary's ErrorBoundary accepts an onReset prop — if your error was caused by a query that's now cached in a failed state, you may also need to call queryClient.resetQueries() there so "Try again" actually retries the fetch instead of immediately re-throwing the same cached error.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can't a normal try/catch in a parent component catch an error thrown while rendering a child?

React's rendering process doesn't run inside a single synchronous call a parent's try/catch could wrap — child components are rendered by React's own internal mechanism, not called directly by the parent's code. Error boundaries exist specifically because catching errors during React's render process requires hooking into React's own component lifecycle, which only a component (currently, a class component) can do.

Q2

What does a component "suspending" actually mean mechanically?

It throws a special promise during render. React catches that thrown promise, shows the nearest Suspense boundary's fallback in its place, and re-renders the real content once the promise resolves — you don't throw this promise yourself; it's built into Suspense-aware tools like useSuspenseQuery.

Q3

With useSuspenseQuery, why does a failed request need an ErrorBoundary instead of an isError check inside the component?

useSuspenseQuery doesn't return an error state to branch on the way useQuery does — its whole design assumption is that a successful render always has real data. When the underlying request fails, it re-throws the error during render instead, which only an error boundary (not a plain if check) is able to catch.

Q4

Why does ErrorBoundary wrap Suspense, rather than the other way around?

The error boundary needs to be positioned to catch anything thrown by its descendants, including a rejected suspense promise re-thrown as a real error — if Suspense were on the outside, an error from inside it wouldn't have an enclosing boundary to be caught by. Putting ErrorBoundary outermost ensures both the pending state (handled by Suspense) and the failure state (handled by the boundary) are covered correctly.