React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 15 of 17 · ~5 min
Error Boundaries and Suspense
Here is the bug that shipped. One candidate's take-home timed out, so the grader never produced a score, so their record comes back with score: null. And chapter 2's badge does this:
function ScorePill({ score }) {
return <span className="pill">{score.toFixed(1)}</span>; // null.toFixed → TypeError
}
The whole dashboard goes white. Not the row — the page. Every other candidate, the filter bar, the header, gone, because React's default response to a render-time error is to tear the whole tree down, not just the piece that broke.
That default is deliberate, and the reasoning behind it is sound: a UI that has thrown mid-render is in an unknown state, and showing a half-rendered screen that might submit the wrong data is worse than showing nothing. But "the whole application" is the wrong blast radius for one bad record.
An error boundary catches errors thrown anywhere below it and renders a fallback in place of that subtree:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true }; // render phase: decide what to show
}
componentDidCatch(error, info) {
logToService(error, info.componentStack); // commit phase: side effects go here
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
The two methods are a pair with a real division of labour. getDerivedStateFromError runs during rendering, must be pure, and exists only to flip state so a fallback renders. componentDidCatch runs after the commit and is where side effects belong — logging to Sentry, reporting the componentStack that tells you which component threw.
Placement is the decision that matters, and it is a product decision more than a technical one. One boundary at the root turns a blank page into an apology page, which is better but not much. Boundaries placed where the tree has natural seams contain the damage:
<ErrorBoundary fallback={<p>The dashboard failed to load.</p>}>
<FilterBar />
<tbody>
{visible.map((candidate) => (
<ErrorBoundary key={candidate.id} fallback={<BrokenRow id={candidate.id} />}>
<CandidateRow candidate={candidate} />
</ErrorBoundary>
))}
</tbody>
</ErrorBoundary>
Now the candidate with the null score shows a row saying their record could not be displayed, and the other seven hundred and ninety-nine are fine. The reviewer keeps working, and you get a log entry with the exact candidate id in it.
What error boundaries do not catch is at least as important, because assuming otherwise leads to a boundary that is silently doing nothing:
- Errors in event handlers. A click handler that throws is ordinary JavaScript, outside the render cycle. Use
try/catch. - Errors in asynchronous code. A
setTimeoutcallback or a rejected promise that is never caught does not happen during rendering. That is what the.catchin chapter 12's hook is for; once the error is in state, rendering it can be caught. - Errors during server-side rendering. A different mechanism handles those.
- Errors thrown in the boundary itself. It cannot catch its own fallback throwing, which propagates to the boundary above.
Error boundaries still have to be class components — there is no hook form of getDerivedStateFromError, and function components have no equivalent. In practice almost nobody hand-writes the class: react-error-boundary provides one with a reset mechanism and a useErrorBoundary hook for pushing async errors into the nearest boundary. React 19 also added onCaughtError and onUncaughtError options when you create a root, which is the clean place to wire up global logging.
Suspense and code splitting
Suspense looks like it belongs to the same conversation, but it solves something else entirely: putting up a placeholder for however long a component has to wait on something it needs that isn't ready yet.
Our submission viewer renders the candidate's code with syntax highlighting, diffing and a language grammar bundle — several hundred kilobytes of JavaScript, for a panel that only opens when a reviewer clicks a row. Right now every visitor downloads it, including the ones who only look at the table.
const SubmissionViewer = React.lazy(() => import("./SubmissionViewer"));
function ReviewDrawer({ candidateId, onClose }) {
const { detail, status } = useCandidateDetail(candidateId);
return (
<Drawer title={detail?.name} onClose={onClose}>
<ErrorBoundary fallback={<p>Could not load the submission viewer.</p>}>
<Suspense fallback={<ViewerSkeleton />}>
<SubmissionViewer submission={detail?.submission} />
</Suspense>
</ErrorBoundary>
</Drawer>
);
}
React.lazy takes a function returning a dynamic import(). The bundler sees that and splits SubmissionViewer and its dependencies into a separate chunk, fetched the first time the component actually renders. Suspense shows the fallback while the chunk is in flight. Together they are code splitting: the initial bundle shrinks by everything that is not needed to show the first screen, which directly shortens the time until the dashboard is interactive.
The error boundary around the Suspense boundary is not decoration. A chunk fetch can fail — a deploy in the middle of a session, a flaky connection — and React.lazy surfaces that as a thrown error during render, which only a boundary can catch. Pairing the two is the standard arrangement: Suspense for "not ready yet", a boundary for "will never be ready".
Two things worth knowing about where Suspense is going. It also handles data, not only lazy components, when the data source is written to support it — framework loaders, and React 19's use hook, which reads a promise and suspends until it resolves. And when an update causes an already-visible subtree to suspend again, wrapping the update in startTransition keeps the current content on screen instead of flashing back to the fallback, which is usually what you want when a reviewer switches between candidates.