Error boundaries
Without a boundary, one component throwing during render unmounts the whole app. What a boundary catches, what it never sees, and how to recover.
After this lesson you can
- Write an error boundary and place it so one failure does not blank the page
- Say which errors a boundary catches and which it cannot see
- Route an error from an event handler or async callback into a boundary, and reset after one
If a component throws while React is rendering it and nothing catches the error, React unmounts the entire tree under that root. A bug in one sidebar widget becomes a blank page. An error boundary limits the damage: it catches errors thrown by the components below it and renders a fallback in their place, leaving the rest of the page alone.
Writing one
There is still no hook for this; a boundary is a class component that
implements static getDerivedStateFromError (to switch to the fallback)
and usually componentDidCatch (to log):
class ErrorBoundary extends React.Component<
{ fallback: React.ReactNode; children: React.ReactNode },
{ error: Error | null }
> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error }; // render the fallback next
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportToMonitoring(error, info.componentStack);
}
render() {
return this.state.error ? this.props.fallback : this.props.children;
}
}
Most teams use the react-error-boundary package rather than writing
this by hand. React 19 also lets you observe errors for a whole root with
two createRoot options: onCaughtError (an error boundary caught it)
and onUncaughtError (nothing did).
Which boundary catches it
An error goes to the nearest boundary above the component that threw.
A boundary does not catch its own errors — if the boundary itself throws
while rendering, the error goes to the boundary above it. Everything
outside the failing boundary keeps rendering, and the boundaries further
up never hear about it. The model below gives two boundaries the shape of
the class above: getDerivedStateFromError runs while rendering and picks
the fallback, componentDidCatch runs once the result is committed. The
same broken weather widget is rendered on three versions of a page:
let log = [];let commitQueue = []; // The two methods of a class boundary, as a plain object.function makeBoundary(name, fallback) { return { state: { error: null }, getDerivedStateFromError(error) { log.push(`render: ${name} switches to its fallback`); return { error }; }, componentDidCatch(error) { log.push(`commit: ${name} reports "${error.message}"`); }, render(children) { return this.state.error ? [fallback] : children(); }, };} // <Boundary>{children}</Boundary>: render the children; if they throw,// derive the error state, render again (now the fallback), and queue// componentDidCatch for after the commit.function within(boundary, children) { try { return boundary.render(children); } catch (error) { boundary.state = { ...boundary.state, ...boundary.getDerivedStateFromError(error) }; commitQueue.push(() => boundary.componentDidCatch(error)); return boundary.render(children); }} function renderRoot(App) { log = []; commitQueue = []; try { const screen = App(); commitQueue.forEach((didCatch) => didCatch()); return { screen, log }; } catch (error) { log.push(`uncaught "${error.message}": the root unmounts`); return { screen: [], log }; }} const Weather = () => { throw new Error("weather API returned 500");}; export function run() { // Weather has its own boundary inside the page's boundary. const page = makeBoundary("PageBoundary", "Page unavailable"); const widget = makeBoundary("WidgetBoundary", "Weather unavailable"); const guarded = () => within(page, () => ["Header", ...within(widget, Weather), "Article"]); // Only the page-level boundary. const pageOnly = makeBoundary("PageBoundary", "Page unavailable"); const coarse = () => within(pageOnly, () => ["Header", ...Weather(), "Article"]); // No boundary at all. const bare = () => ["Header", ...Weather(), "Article"]; return { widgetBoundary: renderRoot(guarded), pageBoundaryOnly: renderRoot(coarse), noBoundary: renderRoot(bare), };}What a boundary never sees
A boundary catches errors thrown while React renders the components below it, in their class lifecycle methods, and in their effects. It does not catch errors thrown:
- in event handlers — they run after rendering, outside React's
render, so a
try/catchin the handler is the tool; - in asynchronous code such as
setTimeoutor a promise callback; - in the boundary component itself.
To show a boundary's fallback for one of those, move the error into
rendering: store it in state, or call a state setter with an updater that
throws, which makes the next render throw. React 19's use(promise) does
this for you — a rejected promise is thrown during render — and so does
the startTransition returned by useTransition: an error thrown by its
action is shown by the nearest boundary above that component.
The model below sketches why the handler case escapes (it is plain
JavaScript, not React): the boundary is a try/catch around rendering,
and a click handler runs later, outside it.
export function run() { const log = []; let pendingThrow = null; // a state updater that throws function boundaryRender(renderChild) { try { return renderChild(); } catch (err) { return `fallback: ${err.message}`; } } function SaveButton() { if (pendingThrow) pendingThrow(); // React runs the updater during render return "button"; } log.push(boundaryRender(SaveButton)); // The click handler runs later, outside any render: try { (function onClick() { throw new Error("save failed"); })(); } catch (err) { log.push(`escaped to the window: ${err.message}`); } // Re-throwing through state: setState(() => { throw err }) pendingThrow = () => { throw new Error("save failed"); }; log.push(boundaryRender(SaveButton)); return log;}Placement and recovery
Put boundaries where a failure has a sensible fallback: around each
route, each independent widget, each third-party embed. To try again,
reset the boundary's state — the simplest way is to change its key,
for example to the current route, so navigating away remounts it fresh.
Common mistakes
- No boundary at all, so any render error blanks the whole app.
- Expecting a boundary to catch an error from an
onClickhandler. It escapes, because the handler runs outside rendering (the second example models this); catch it in the handler or push it into state. - One boundary at the root only. It works, but the fallback replaces the whole page instead of the one broken widget.
- A fallback that can itself throw (it reads the same broken data). The error goes to the next boundary up.
What interviewers ask
- What errors do error boundaries not catch? — Errors in event handlers, in asynchronous callbacks such as timers and promise callbacks, and in the boundary itself.
- Can you write an error boundary as a function component? — Not with
React's own API: a boundary needs
getDerivedStateFromError/componentDidCatch, which only class components have; a library such asreact-error-boundarywraps that class for you. - How would you show a fallback for a failed fetch in a click handler? — Catch it and put it in state (render an error message, or throw it from render so the nearest boundary shows its fallback).
Exercise · Pro
Where each error ends up
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade