If a component throws while rendering, React unmounts your entire application. Not the broken component — all of it. The user gets a blank white page and the error goes to the console, which they will never see.
That behaviour is deliberate. React's position is that a UI showing wrong data is worse than a UI showing nothing, and it has no way to know which parts of your tree are still trustworthy. An error boundary is how you tell it.
What a boundary is
A component that catches errors thrown anywhere below it and renders a fallback instead of
crashing. It is still, in 2026, the one thing that requires a class component —
there is no hook equivalent of componentDidCatch.
Here is the whole thing from the pizza app:
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Alert, Button, Container } from 'react-bootstrap';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
/** Runs during the render phase and decides the new state — must stay side-effect free. */
static getDerivedStateFromError(error: Error): State {
return { error };
}
/** Runs after the error is committed. This is where logging belongs. */
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// In production this would go to Sentry, Datadog or similar.
console.error('Uncaught render error:', error, errorInfo.componentStack);
}
handleReset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return (
<Container className="py-5">
<Alert variant="danger">
<Alert.Heading>Something went wrong</Alert.Heading>
<p className="mb-3">
Sorry — this part of the page failed to load. Your cart has not been lost.
</p>
<pre className="small bg-light p-2 rounded overflow-auto">
{this.state.error.message}
</pre>
<Button variant="outline-danger" onClick={this.handleReset}>
Try again
</Button>
</Alert>
</Container>
);
}
return this.props.children;
}
}Two methods, and they run at different times for different reasons:
getDerivedStateFromErrorisstaticand runs during the render phase. It returns the new state — that is all it may do. No logging, no requests, no side effects, because React may discard this render and try again.componentDidCatchruns after the error is committed, so side effects are fine. This is where reporting belongs. ItserrorInfo.componentStackis genuinely useful: it names the React components on the path to the throw, which a JavaScript stack trace does not.
You need both. The first shows the fallback; the second tells you it happened.
Where to put it
A boundary catches everything below it and nothing above it, so placement is a decision about how much of the page you are willing to lose.
The pizza app puts one around the routed content — inside the layout, outside the pages:
<div className="d-flex flex-column min-vh-100">
<AppNavbar onOpenCart={() => setCartOpen(true)} />
<main className="flex-grow-1">
{/* Any render error inside a route is caught here rather than blanking the whole app. */}
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<Routes>{/* … */}</Routes>
</Suspense>
</ErrorBoundary>
</main>
<CartDrawer show={cartOpen} onHide={() => setCartOpen(false)} />
<Footer />
</div>So a crash in the checkout page leaves the navbar, the cart drawer and the footer alive. The user still has their cart, can still navigate, and sees an explanation rather than nothing. That is the whole benefit, and it comes from where the boundary sits, not from the boundary itself.
A route-level boundary is the sensible default. Add narrower ones around genuinely independent widgets — a chart, an embedded third-party thing, a comment feed — where the rest of the page is perfectly usable without them. What you do not want is one boundary at the very root, which catches everything and therefore loses everything.
What it does not catch
This is the part people get wrong. An error boundary only catches errors thrown while rendering, in lifecycle methods, and in constructors of the tree below it. It does not catch:
- Event handlers. A click handler that throws is not part of rendering.
- Async code. A rejected promise, a
setTimeoutcallback, anything after anawait. - Errors in the boundary itself. They propagate to the next boundary up.
- Server rendering.
Which covers most of what actually goes wrong in a real application — failed requests happen far
more often than a component throwing during render. Those need ordinary try/catch and a
state variable:
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch {
// The error message is already surfaced through AuthContext; nothing to do here.
}
}…and, in the context that owns the request, an error field the UI can render:
} catch (err) {
// An abort is not a failure — it means we navigated away or StrictMode re-ran the effect.
if (controller.signal.aborted) return;
setError(
err instanceof Error ? `Could not load the menu: ${err.message}` : 'Could not load the menu.',
);
}{error && (
<Alert variant="danger" className="d-flex justify-content-between align-items-center">
<span>{error}</span>
<Button size="sm" variant="outline-danger" onClick={reload}>
Try again
</Button>
</Alert>
)}Think of it as two separate systems. Expected failures — the network is down, the password is wrong — are modelled as state and rendered. Error boundaries are for the unexpected: the bug you did not know you had.
Forcing an async error into a boundary
If you do want an async failure to hit the boundary, rethrow it during render:
const [error, setError] = useState<Error | null>(null);
if (error) throw error; // now it is a render-phase error, so the boundary catches it
useEffect(() => {
load().catch(setError);
}, []);Use this sparingly. Most failures deserve a message in place, not a wiped-out page.
Recovering
The boundary above offers a "Try again" button that clears the error state and re-renders the children. That works when the failure was transient. When it was not — bad data in props, say — the children throw again immediately and the user is stuck in a loop.
The more reliable reset is to change the boundary's key when the thing that failed
changes, which unmounts the whole subtree and builds a fresh one:
{/* A new route means a genuinely fresh attempt, not a retry of the same broken state. */}
<ErrorBoundary key={location.pathname}>
<Routes>{/* … */}</Routes>
</ErrorBoundary>Same mechanism as resetting a component with a key.
The library version
If writing a class bothers you, react-error-boundary wraps this up with a
FallbackComponent prop, an onReset callback, resetKeys, and a
useErrorBoundary hook for throwing from async code. It is a small, well-maintained
package and it is a reasonable default on a new project.
It is still a class underneath. There is no way around that yet.