If a child component throws during render, React can unmount the whole subtree beneath it. Related: React State Management Patterns—another area where the class-vs-hooks split shows up in practice.
Without containment, one bad leaf can leave users staring at a blank page—the informal “white screen of death.” React error boundaries exist to catch rendering errors and show a fallback UI instead of tearing down the entire app.
Most greenfield React code today is functional components and hooks. Error boundaries are the awkward exception: the supported API still lives on a class component. This post explains why, which lifecycle methods matter, what boundaries never catch, and a minimal pattern that keeps the rest of your tree function-based.
What an error boundary actually is
An error boundary is a React component that implements a specific error-handling contract with the reconciler. During a commit, if React hits an uncaught error in certain phases of the tree below the boundary, it can:
- Capture the error instead of propagating it to the root.
- Store enough state to know a failure happened.
- Render a fallback (or delegate to a parent boundary).
The feature is deliberately narrow. It is not a general try/catch for all JavaScript failures in your component tree—more on that below.
Why error boundaries are class components today
React’s public API for this behavior is still expressed in terms of class lifecycle hooks that do not have first-class hook equivalents in React’s stable core:
| Lifecycle | Role |
|---|---|
static getDerivedStateFromError(error) | Runs during render when a descendant throws. Returns an object to update state from the error (for example, { hasError: true }) so the next render can branch to fallback UI. |
componentDidCatch(error, info) | Runs after a commit. Use it for side effects: logging to an APM, console.error, reporting componentStack from info, avoiding duplicate reports, etc. |
Functional components cannot declare static methods or these class-only lifecycle entry points through useEffect/useState alone, because the reconciler invokes these hooks at specific times in the error recovery path—not as ordinary “effects after paint.” Until React exposes a blessed hook or component primitive with the same semantics, the documented, stable way to author a boundary is a class.
That is the entire answer to “why are React error boundaries only class components?” It is not ideology; it is API surface. You can still wrap function components—your app stays hooks-first; one small class (or a library wrapper) sits at the edge of a subtree.
Minimal class boundary (copy-paste pattern)
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = {
fallback: ReactNode;
children: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
};
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info);
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}
Usage around functional routes, widgets, or data-heavy subtrees:
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<DashboardShell />
</ErrorBoundary>
Production tip: pair componentDidCatch with your logging pipeline (Sentry, Datadog, OpenTelemetry, etc.) and include info.componentStack—it shortens time-to-root-cause dramatically.
What error boundaries do not catch
Treat this as a hard mental split, not a vague “maybe.” Do not assume a boundary shields you from:
- Event handler errors wrap synchronous handler code in
try/catch, or handle promise rejections from async handlers yourself. - Asynchronous errors set after render (
setTimeout, manyPromisechains,fetchcallbacks) unless they happen to throw during a phase React is already turning into a render error boundary path. - Errors in the boundary itself a broken fallback or a throw inside
getDerivedStateFromErrordoes not magically self-heal.
The React docs stress this split: error boundaries are for render-time failures in the tree below them, not a substitute for normal defensive coding in IO and events.
Strategy: where to place boundaries
Think in blast-radius units:
- Route-level one boundary per major page or lazy-loaded chunk so a bad panel does not blank the shell.
- Feature-level isolate experimental or data-heavy widgets (charts, rich text, third-party embeds).
- Don’t over-nest too many stacked boundaries make UX and logging noisier; align with your design system’s fallback patterns.
Libraries such as react-error-boundary wrap the same lifecycle contract behind a declarative API; under the hood the mechanics are still “class boundary semantics,” not a different reconciler feature.
Mental model checklist
- Need to stop a render-time exception from killing the app? Add a boundary above the risky subtree.
- Need to catch a click-handler or failed
fetch? Usetry/catch,Resulttypes, orawaitin anasyncevent path—not a boundary alone. - Want hooks everywhere else? Keep using them; one class file (or library) at the edge is normal in modern React codebases.
If React adds a first-party hook or primitive with the same catch-and-recover semantics as class boundaries, you can adopt it incrementally behind the same placement ideas above. As of today, getDerivedStateFromError and componentDidCatch are still the supported surface for that behavior in app code.