Without error boundaries, one throwing component unmounts the entire app and leaves users with a blank page.
If a component throws during rendering, React can't produce a consistent UI, so by default it unmounts the whole tree. An error boundary limits the damage: it catches errors thrown while rendering its descendants (including in their lifecycle and effects, and rejected promises read with use) and renders a fallback instead.
Error boundaries are still written as class components, the one place modern code needs them. A class becomes a boundary by defining static getDerivedStateFromError(error) (returns state to render the fallback) and optionally componentDidCatch(error, info) (for logging; info.componentStack shows where). Most teams use the small `react-error-boundary` package instead of writing one:
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Comments />
</ErrorBoundary>Error boundaries do not catch errors in event handlers (those don't happen during rendering; use try/catch and set state), in async code outside rendering (setTimeout, a fetch in a handler), or in the boundary itself.
Placement is a design choice, like Suspense boundaries: one at the top as a last resort, plus finer ones around independent widgets (a sidebar, a chart, a route) so a failure in one doesn't take down the rest. Offer a retry: react-error-boundary gives the fallback a resetErrorBoundary function, and changing the boundary's key also remounts it fresh.
React 19 improves reporting: errors are logged once rather than re-thrown and duplicated, and createRoot accepts onCaughtError (errors caught by a boundary), onUncaughtError and onRecoverableError options, which is the right hook for sending errors to a monitoring service.
Example
import { Component, Suspense, use } from "react";
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // render fallback next
}
componentDidCatch(error, info) {
console.error("Caught:", error, info.componentStack); // report to monitoring
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Couldn't load this section.</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
);
}
return this.props.children;
}
}
const failing = Promise.reject(new Error("503 from API"));
function Comments() {
const comments = use(failing); // rejection is thrown to the boundary
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
export default function Post() {
return (
<ErrorBoundary>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments />
</Suspense>
</ErrorBoundary>
);
}Remember
- Error boundaries catch render-time errors in their subtree and show a fallback
- They're class components (
getDerivedStateFromError);react-error-boundarywraps this nicely - They don't catch event-handler or unrelated async errors; use try/catch there
Watch out for
- Expecting an error boundary to catch an exception thrown inside an onClick handler.