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.
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.
npm install react-error-boundary
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.
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.
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.
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>;
}
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
}
<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:
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.
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
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:
- Take Week 8's product detail page (or rebuild a similar one) and convert its data fetching from
useQuerytouseSuspenseQuery. - Wrap it in an
ErrorBoundary+Suspensepair, correctly nested per Section 5, with a skeleton fallback and a fallback component showing the error message plus a "Try again" button usingresetErrorBoundary. - Make your simulated fetch function randomly throw for an invalid product ID, and confirm the error boundary — not a blank screen — catches it.
- 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.
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?
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?
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?
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?
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.