Concept
The beginner framing: an error boundary is a component that catches JavaScript errors thrown anywhere in its child tree during rendering, logs them, and displays a fallback UI instead of crashing the whole application.
The precise mental model: when a component throws during render, React doesn't just stop that one component, by default, it unmounts the entire tree below the nearest error boundary, because React can no longer trust that any of that subtree's state is consistent. An error boundary catches the thrown error at that point and substitutes a fallback UI for the entire subtree, instead of the whole app going blank (or a much larger portion of it).
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true }; // triggers a re-render with the fallback
}
componentDidCatch(error, info) {
logErrorToService(error, info.componentStack); // side effect: report it
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
<ErrorBoundary fallback={<h1>Something went wrong.</h1>}>
<ProfilePage />
</ErrorBoundary>The one place a class component is still required
As of today, there is no hook equivalent of getDerivedStateFromError or componentDidCatch, useEffect cannot catch a render-phase error the way these two class lifecycle methods can. If you need error boundary behavior, the boundary component itself must be a class; everything else it wraps can (and should) still be ordinary function components.
getDerivedStateFromError(error)is a static method used purely to compute the next state that renders the fallback UI, it runs during the render phase and must be pure, exactly like the rest of render (see Rendering Lifecycle).componentDidCatch(error, info)runs during the commit phase and is where side effects belong, logging the error to a monitoring service, for instance.
This split mirrors the render/commit separation from Rendering Lifecycle precisely: the state update happens in the pure phase, the side effect happens in the phase reserved for side effects.
What error boundaries do NOT catch
- Errors inside event handlers (use an ordinary
try/catchthere instead, event handlers aren't part of the render phase at all). - Errors in asynchronous code (
setTimeout, promise callbacks) that aren't thrown synchronously during render. - Errors during server-side rendering.
- Errors thrown inside the error boundary's own
componentDidCatchimplementation.
Try It
Predict what happens before checking the solution.
function Broken() {
throw new Error("Something broke!");
}
function App() {
return (
<ErrorBoundary fallback={<p>Recovered.</p>}>
<Header />
<Broken />
<Footer />
</ErrorBoundary>
);
}Does Header stay on screen after Broken throws?
Solution
No, Header and Footer both disappear too. An error boundary replaces its entire subtree with the fallback, not just the component that threw. Broken, Header, and Footer are all children of the same ErrorBoundary, so all three are unmounted together and replaced by <p>Recovered.</p>. This is exactly why boundary placement matters, a boundary wrapping the whole app means one broken widget takes down the entire UI; a boundary wrapping just the risky widget contains the blast radius to that widget alone.
Implement It Yourself
Build a minimal, functioning error boundary from scratch to see exactly how the two lifecycle methods divide responsibility:
class MinimalErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
// PURE, just compute the next state. No logging, no side effects here.
return { error };
}
componentDidCatch(error, errorInfo) {
// SIDE EFFECT phase, safe to log, report, or notify here.
console.error("Caught by boundary:", error, errorInfo.componentStack);
}
render() {
if (this.state.error) {
return
Notice the deliberate split: getDerivedStateFromError only ever returns a plain object, no console.error, no network call, while componentDidCatch is where all the "do something with this error" logic lives. Mixing them up (logging inside getDerivedStateFromError) would violate the same purity requirement that governs the rest of the render phase.
Under the Hood
A thrown error propagating up to the nearest error boundary is structurally identical to a thrown exception propagating up to the nearest try/catch in plain JavaScript (see Error Handling), React is simply providing a component-tree-shaped version of the same bubbling mechanism, where the "catch" is a component instead of a syntactic block. And getDerivedStateFromError living in the render phase (pure) versus componentDidCatch living in the commit phase (side effects allowed) is the exact same render/commit split from Rendering Lifecycle, just applied to error handling specifically instead of ordinary rendering.
Common Mistakes
1. Expecting an error boundary to catch event handler errors
<ErrorBoundary>
<button onClick={() => { throw new Error("boom"); }}>Click</button>
</ErrorBoundary>
// ❌ this error is NOT caught, event handlers run outside the render phaseEvent handlers execute in response to a browser event, entirely outside of React's render cycle, error boundaries only catch errors thrown during rendering. Wrap the handler's logic in an ordinary try/catch instead.
2. Putting a single error boundary at the very root and nowhere else
<ErrorBoundary fallback={<FullPageError />}>
<App /> {/* any error ANYWHERE takes down the entire UI */}
</ErrorBoundary>One root-level boundary means a single broken widget (a third-party embed, a flaky data-dependent chart) blanks the entire application. Placing boundaries around individual risky or independent sections contains failures to just that section.
3. Forgetting a way to reset the boundary's error state
Once getDerivedStateFromError sets hasError: true, the fallback stays forever unless something resets that state, commonly done by giving the boundary (or the subtree it wraps) a key that changes on retry, forcing a full remount (see Reconciliation), or by exposing a "Try again" button that calls this.setState({ error: null }).
Best Practices
- Place boundaries around independent, isolated sections (a chart widget, a comments section, a third-party embed) rather than only at the application root, contain failures instead of letting one broken part take down everything.
- Log the error in
componentDidCatch, notgetDerivedStateFromError, keep the pure state computation and the side-effecting report cleanly separated. - Always provide a way to recover, whether a "Try again" button that resets the boundary's state, or a
keychange that remounts the failed subtree entirely. - Combine with Suspense thoughtfully, an error boundary catches thrown errors; a
<Suspense>boundary catches thrown promises (see Suspense); they solve related but distinct problems and are often nested together.
Performance Tips
- Error boundaries themselves have no meaningful runtime cost, they only do anything at all when an error is actually thrown, so there's no reason to avoid using several of them at different levels of the tree out of performance concern.
- Excessively broad boundary placement (Common Mistake #2) is a UX and reliability cost far more than a performance one, the "cost" of a missing boundary is a fully blanked UI, not a slow one.
