Error Boundaries
A crash in one component shouldn’t blank the whole app
Section titled “A crash in one component shouldn’t blank the whole app”If a component throws during render, React — by design — unmounts the entire tree rather than show a corrupt UI. An error boundary is a component that catches such errors in its subtree, so a failure in one widget shows a fallback there instead of taking down the whole page.
Error boundaries are still written as class components, because they rely on two lifecycle methods with no hook equivalent:
import { Component } from "react";
class ErrorBoundary extends Component { state = { error: null };
// Render phase: return new state to show a fallback on the next render. static getDerivedStateFromError(error) { return { error }; }
// Commit phase: side effect — log the error to your reporting service. componentDidCatch(error, info) { reportError(error, info.componentStack); }
render() { if (this.state.error) return this.props.fallback; return this.props.children; }}In app code most people use the tiny react-error-boundary library, which wraps this and adds a reset mechanism — but the mechanics are exactly the above.
<ErrorBoundary fallback={<p>Something went wrong.</p>}> <Widget /> {/* if Widget throws while rendering, the fallback shows here */}</ErrorBoundary>What they catch — and what they don’t
Section titled “What they catch — and what they don’t”This is the part people get wrong. Error boundaries catch errors that happen during rendering (and in lifecycle methods and constructors) of the components below them. They do not catch:
- Event handlers — an error in an
onClickis just a normal JS error; usetry/catch. - Asynchronous code — a rejected promise or a
setTimeoutcallback runs outside React’s render, so no boundary sees it. - Errors in the boundary itself — only errors below it.
- Server-side rendering errors — handled separately.
flowchart TB eb["Error Boundary"] --> child["child renders"] child -->|throws during render| caught["caught → show fallback"] handler["onClick throws"] -.NOT caught.-> handler2["use try/catch"] async["promise rejects / setTimeout"] -.NOT caught.-> async2["handle it yourself"]
Pairing with Suspense
Section titled “Pairing with Suspense”Suspense and error boundaries are complementary: Suspense handles the loading state (a child is waiting), an error boundary handles the failure state (a child threw — including a rejected promise read by use()). Together they give you the three states of async UI declaratively:
<ErrorBoundary fallback={<Error />}> <Suspense fallback={<Spinner />}> <Profile /> {/* loading → Spinner, error → Error, success → Profile */} </Suspense></ErrorBoundary>When use() reads a promise that rejects, React propagates it to the nearest error boundary — so the same boundary that handles render crashes also handles data-fetch failures.
onCaughtError / onUncaughtError / onRecoverableError (React 19)
Section titled “onCaughtError / onUncaughtError / onRecoverableError (React 19)”Since React 19, createRoot (and hydrateRoot) accept options that let you observe every error React handles — instead of relying only on console.error output or a single top-level error boundary:
“
onCaughtError: Callback called when React catches an error in an Error Boundary. Called with theerrorcaught by the Error Boundary, and anerrorInfoobject containing thecomponentStack.” — react.dev,createRoot“
onUncaughtError: Callback called when an error is thrown and not caught by an Error Boundary.” — same reference“
onRecoverableError: Callback called when React automatically recovers from errors.” — same reference (this is the hook for hydration mismatches React repairs on its own)
import { Component, type ReactNode } from 'react';import { act } from 'react';import { createRoot } from 'react-dom/client';
// This file doesn't import @testing-library/react (whose import has the side// effect of enabling React's test-act environment), so it's set explicitly.(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) return <p>fallback</p>; return this.props.children; }}
function ThrowsCaught(): ReactNode { throw new Error('caught-me');}
function ThrowsUncaught(): ReactNode { throw new Error('uncaught-me');}
test('onCaughtError fires with errorInfo.componentStack when a boundary catches', () => { const container = document.createElement('div'); document.body.appendChild(container); const calls: { message: string; hasStack: boolean }[] = []; const root = createRoot(container, { onCaughtError(error, errorInfo) { calls.push({ message: (error as Error).message, hasStack: typeof errorInfo.componentStack === 'string' && errorInfo.componentStack.includes('ThrowsCaught'), }); }, onUncaughtError() {}, }); act(() => { root.render( <Boundary> <ThrowsCaught /> </Boundary>, ); }); expect(calls).toHaveLength(1); expect(calls[0].message).toBe('caught-me'); expect(calls[0].hasStack).toBe(true); // componentStack names ThrowsCaught root.unmount(); container.remove();});
test('onUncaughtError fires when no boundary catches the error', async () => { const container = document.createElement('div'); document.body.appendChild(container); const uncaught: string[] = []; const root = createRoot(container, { onCaughtError() {}, onUncaughtError(error) { uncaught.push((error as Error).message); }, }); // Deliberately not wrapped in act(): act() forces a synchronous rethrow to // the caller for its own flush-and-check semantics, which is a test-harness // artifact, not what root.render does in a real app. Calling render() // directly proves the real, documented behavior: onUncaughtError receives // the error asynchronously and render() does not throw to its caller. root.render(<ThrowsUncaught />); await new Promise((r) => setTimeout(r, 0)); expect(uncaught).toEqual(['uncaught-me']); root.unmount(); container.remove();});✓ onCaughtError fires with errorInfo.componentStack when a boundary catches✓ onUncaughtError fires when no boundary catches the errorWiring these up in your app’s entry point is the modern replacement for hoping errors show up in console.error:
const root = createRoot(document.getElementById("root"), { onCaughtError(error, errorInfo) { reportToMonitoring(error, errorInfo.componentStack); }, onUncaughtError(error, errorInfo) { reportToMonitoring(error, errorInfo.componentStack, { severity: "fatal" }); }, onRecoverableError(error, errorInfo) { reportToMonitoring(error, errorInfo.componentStack, { severity: "hydration" }); },});Reset pattern (resetKeys, without the library)
Section titled “Reset pattern (resetKeys, without the library)”An error boundary’s own state (this.state.error) only clears if you clear it — React doesn’t automatically retry a crashed subtree just because its props changed. The react-error-boundary library’s resetKeys is the standard pattern for “clear the error once whatever caused it has actually changed”; you can hand-roll the same thing with componentDidUpdate:
import { Component, useState, type ReactNode } from 'react';import { render, screen, fireEvent } from '@testing-library/react';
// Remember the resetKeys array from props; if we're currently showing an// error AND any resetKey changed since the last render, clear the error and// try the children again.class ResettableBoundary extends Component< { children: ReactNode; resetKeys: unknown[] }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } componentDidUpdate(prevProps: { resetKeys: unknown[] }) { if (this.state.error && prevProps.resetKeys.some((k, i) => k !== this.props.resetKeys[i])) { this.setState({ error: null }); } } render() { if (this.state.error) return <p data-testid="fallback">error: {this.state.error.message}</p>; return this.props.children; }}
function Widget({ broken }: { broken: boolean }) { if (broken) throw new Error('widget-broken'); return <p data-testid="widget-ok">ok</p>;}
function App() { const [broken, setBroken] = useState(true); return ( <div> <button data-testid="fix" onClick={() => setBroken(false)}>fix</button> <ResettableBoundary resetKeys={[broken]}> <Widget broken={broken} /> </ResettableBoundary> </div> );}
test('resetKeys-style pattern: changing a resetKey after fixing the underlying cause clears the boundary', () => { render(<App />); expect(screen.getByTestId('fallback').textContent).toBe('error: widget-broken');
// Fixing the cause AND changing the resetKey (broken flips to false) in // the same update clears the boundary and retries the children. fireEvent.click(screen.getByTestId('fix')); expect(screen.queryByTestId('fallback')).toBeNull(); expect(screen.getByTestId('widget-ok')).toBeTruthy();});
test('resetKeys-style pattern: re-rendering with the SAME resetKeys does not clear a live error', () => { function StaticApp() { return ( <ResettableBoundary resetKeys={['same']}> <Widget broken={true} /> </ResettableBoundary> ); } const { rerender } = render(<StaticApp />); expect(screen.getByTestId('fallback')).toBeTruthy(); rerender(<StaticApp />); // resetKeys didn't change, so the boundary must not clear itself. expect(screen.getByTestId('fallback')).toBeTruthy();});✓ resetKeys-style pattern: changing a resetKey after fixing the underlying cause clears the boundary✓ resetKeys-style pattern: re-rendering with the SAME resetKeys does not clear a live errorProving what a boundary does NOT catch: an event handler throw
Section titled “Proving what a boundary does NOT catch: an event handler throw”import { Component, type ReactNode } from 'react';import { render, screen, fireEvent } from '@testing-library/react';
class EBoundary extends Component<{ children: ReactNode }, { error: boolean }> { state = { error: false }; static getDerivedStateFromError() { return { error: true }; } render() { if (this.state.error) return <p data-testid="eb-fallback">fallback</p>; return this.props.children; }}
function ClickBoom() { return ( <button data-testid="boom-btn" onClick={() => { throw new Error('event-handler-boom'); }} > click </button> );}
test('an error boundary does NOT catch a throw from an event handler', () => { render( <EBoundary> <ClickBoom /> </EBoundary>, ); // React's event system doesn't route a handler's throw through any error // boundary — it surfaces as a plain uncaught/global error, same as any // other JS throw outside of React's render. let globalMessage = ''; const onUncaught = (e: ErrorEvent) => { globalMessage = e.error?.message ?? e.message; }; window.addEventListener('error', onUncaught); try { fireEvent.click(screen.getByTestId('boom-btn')); } finally { window.removeEventListener('error', onUncaught); } expect(globalMessage).toContain('event-handler-boom'); // The boundary never activated — the button (not a fallback) is still there. expect(screen.queryByTestId('eb-fallback')).toBeNull(); expect(screen.getByTestId('boom-btn')).toBeTruthy();});✓ an error boundary does NOT catch a throw from an event handlerThis confirms the table above with a real, observed result: the throw never reaches EBoundary — it surfaces as a global/uncaught error instead, exactly like any plain JavaScript exception outside of React’s render.
Mental Model
Section titled “Mental Model”An error boundary is a circuit breaker for a subtree, not a global try/catch. It only trips for failures in the wiring it actually owns — the render, lifecycle, and constructor calls of components underneath it. An event handler, a setTimeout callback, or a rejected promise not read via use() runs on a completely different “circuit” (the browser’s own event loop / your own async code), so no breaker anywhere sees it; you wire your own try/catch there. onCaughtError/onUncaughtError/onRecoverableError sit one level above every individual breaker — the building’s master panel — so you can log every trip, whether or not any breaker actually caught it.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| An error boundary around a widget | One broken widget doesn’t blank the whole page | Must be a class component (no hook equivalent) |
resetKeys-style reset | The boundary retries automatically once the real cause changed | You must correctly identify which prop/value means “the cause changed” |
onCaughtError/onUncaughtError | Centralized, reliable error reporting for every render error in the app | Only covers render-phase errors — still need your own try/catch for handlers and async code |
onRecoverableError | Visibility into hydration mismatches React silently repaired | Doesn’t stop the mismatch from happening — it’s observability, not a fix |
Common Misconceptions
Section titled “Common Misconceptions”- “Wrapping event handler code in the same component as an error boundary is enough.” It isn’t — the boundary only catches errors from rendering, not from an
onClick(or any) handler, proven above. Handlers need their owntry/catch. - A boundary automatically retries once its props change. It doesn’t, by default —
this.state.errorstays set until you explicitly clear it.resetKeys(or your owncomponentDidUpdatecheck) is what makes “the cause changed, try again” actually happen. console.erroroutput is a reliable way to know an app has real errors.onCaughtError/onUncaughtErrorare the React-19-native, structured way to hook every error into your own monitoring — with the exactcomponentStack, not just log text.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated error-handling code, check for:
- Does a component wrap risky synchronous event-handler code in an error boundary and assume that’s sufficient? It isn’t — event handlers need
try/catch, proven above. - Does a “retry” button just re-render the same tree without clearing the boundary’s error state (or without a resetKeys-equivalent check)? The fallback will never go away.
- Is
componentDidCatch/getDerivedStateFromErrorused to swallow every error silently (no reporting, no fallback UI), hiding real bugs from both users and your monitoring? - If the app uses
createRoot({ onCaughtError, onUncaughtError }), does the generated code still also expectconsole.errorto be the only signal? The callbacks are more reliable and structured.
🐛 Spot the bug
class ErrorBoundary extends Component {state = { error: null };
static getDerivedStateFromError(error) { return { error };}
handleRetry = () => { this.setState({ error: null }); // no check on WHY it's being retried};
render() { if (this.state.error) { return ( <div> <p>Something went wrong.</p> <button onClick={this.handleRetry}>Try again</button> </div> ); } return this.props.children;}}Show the answer
This boundary’s “Try again” button clears this.state.error unconditionally — but nothing about the underlying cause has changed just because the user clicked a button. If this.props.children still throws for the exact same reason (bad data, a broken import, whatever crashed originally), the boundary immediately re-catches the same error and shows the fallback again, giving the illusion of a retry that never actually retries anything new. A real reset needs either a resetKeys check (only clear when a value tied to the actual cause changed) or the retry action to also re-fetch/re-load whatever was broken before clearing the error.