Stop one broken component from blanking the whole page, and learn what error boundaries can and cannot catch.

Bishal Biswas
Self Learner
What happens without one
If a component throws during render, React unmounts the entire tree. The user gets a blank white page.
function UserCard({ user }) {
return <h2>{user.name.toUpperCase()}</h2>;
}
One null user, and the whole application disappears. That behaviour is deliberate: React would rather show nothing than a corrupted interface. But it is not what you want in production.
An error boundary catches the error and shows a fallback instead, leaving the rest of the page working.
Writing one
Error boundaries are the one thing that still requires a class component. There is no hook equivalent.
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('Caught by boundary:', error, info.componentStack);
reportToMonitoring(error, info);
}
render() {
if (this.state.hasError) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, () =>
this.setState({ hasError: false, error: null })
);
}
return this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
Two methods:
getDerivedStateFromErrorupdates state so the fallback renders.componentDidCatchis where you log and report. It receives the component stack, which tells you exactly which component threw.
Most people install react-error-boundary instead of writing this, and that is a reasonable choice.
Using it
function App() {
return (
<ErrorBoundary fallback={<p>The dashboard is unavailable.</p>}>
<Dashboard />
</ErrorBoundary>
);
}
What it does not catch
This list matters, because people assume boundaries catch everything.
Event handlers.
function Button() {
function handleClick() {
throw new Error('Not caught by a boundary');
}
return <button onClick={handleClick}>Click</button>;
}
Use try/catch instead:
function handleClick() {
try {
doSomethingRisky();
} catch (error) {
setError(error);
}
}
Async code.
useEffect(() => {
fetchData().then(setData);
}, []);
A rejected promise is not caught. Handle it, or store it in state and throw during render:
function Component() {
const [error, setError] = useState(null);
if (error) throw error;
useEffect(() => {
fetchData().then(setData).catch(setError);
}, []);
}
That trick converts an async failure into a render error the boundary can see.
Server rendering. Boundaries only work on the client.
Errors in the boundary itself. They propagate to the next boundary up.
Where to place them
One at the root, as a safety net
function App() {
return (
<ErrorBoundary fallback={<FullPageError />}>
<Router />
</ErrorBoundary>
);
}
Better than a white screen, but the whole app still disappears.
Around independent sections
function Dashboard() {
return (
<div>
<ErrorBoundary fallback={<WidgetError name="Revenue" />}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="Activity" />}>
<ActivityFeed />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="Tasks" />}>
<TaskList />
</ErrorBoundary>
</div>
);
}
Now one broken widget shows an error card and the other two keep working. This is where boundaries earn their place.
Put them around things that can fail independently: widgets, routes, third party embeds, anything rendering user supplied content.
Around each route
<Route
path="/products"
element={
<ErrorBoundary fallback={<RouteError />}>
<ProductsPage />
</ErrorBoundary>
}
/>
A broken page keeps the navigation working, so the user can go somewhere else.
Recovering
A fallback the user cannot escape is only half a solution.
<ErrorBoundary
fallback={(error, reset) => (
<div role="alert">
<p>This section failed to load.</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<Widget />
</ErrorBoundary>
reset clears the boundary’s state and re-renders the children. If the cause was transient, such as a failed request, it recovers.
Reset automatically when the route changes:
<ErrorBoundary key={location.pathname} fallback={<RouteError />}>
<Outlet />
</ErrorBoundary>
Changing the key remounts the boundary, which clears the error. The key trick again.
React 19 root options
React 19 added handlers on the root for errors that boundaries do not surface.
import { createRoot } from 'react-dom/client';
createRoot(container, {
onCaughtError(error, info) {
reportToMonitoring(error, info, { caught: true });
},
onUncaughtError(error, info) {
reportToMonitoring(error, info, { caught: false });
},
onRecoverableError(error, info) {
console.warn('Recovered from', error);
}
}).render(<App />);
onCaughtErrorfires when a boundary caught it.onUncaughtErrorfires when nothing caught it.onRecoverableErrorfires for problems React handled itself, most often a hydration mismatch.
This gives you one central place to report every error, rather than adding logging to every boundary.
Development versus production
In development you see the error overlay even with a boundary in place, which is deliberate so you cannot miss the problem. Dismiss it to see your fallback.
In production only the fallback shows. Always test what your fallback actually looks like, because a fallback that itself throws is a bad day.
Quick recap
- Without a boundary, one render error blanks the entire app.
- Boundaries must be class components, or use
react-error-boundary. - They do not catch event handlers, async code, or server rendering.
- Convert an async failure into a render error by throwing from state.
- Put boundaries around independently failing sections, not just the root.
- Give the fallback a reset action, or key it to the route.
- React 19 root options centralise error reporting.
- Always test the fallback in a production build.
import { Component, useState, useEffect } from 'react';
// Error boundaries are the ONE thing that still needs a class component.
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error }; // show the fallback
}
componentDidCatch(error, info) {
// info.componentStack names the component that threw
console.error('Caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
const reset = () => this.setState({ hasError: false, error: null });
return typeof this.props.fallback === 'function'
? this.props.fallback(this.state.error, reset)
: this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
// Place them around INDEPENDENT sections, so one broken widget does not
// take the other two down with it
export default function Dashboard({ pathname }) {
return (
<div>
<ErrorBoundary
fallback={(error, reset) => (
<div role="alert">
<p>The revenue chart failed to load.</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Activity is unavailable.</p>}>
<ActivityFeed />
</ErrorBoundary>
{/* Keying to the route clears the error on navigation */}
<ErrorBoundary key={pathname} fallback={<p>This page failed.</p>}>
<Outlet />
</ErrorBoundary>
</div>
);
}
// WHAT BOUNDARIES DO NOT CATCH:
// 1. Event handlers. Use try/catch.
function SaveButton({ onSave }) {
const [error, setError] = useState(null);
async function handleClick() {
try {
await onSave();
} catch (err) {
setError(err); // boundaries never see this
}
}
return <button onClick={handleClick}>{error ? 'Failed' : 'Save'}</button>;
}
// 2. Async code. Convert it into a render error so the boundary can see it.
function DataPanel() {
const [error, setError] = useState(null);
const [data, setData] = useState(null);
if (error) throw error; // now a boundary above WILL catch it
useEffect(() => {
fetchData().then(setData).catch(setError);
}, []);
return <p>{data?.title}</p>;
}
// 3. Server rendering, and errors thrown inside the boundary itself.
// REACT 19: report every error from one place, caught or not
// createRoot(container, {
// onCaughtError(error, info) { report(error, info, { caught: true }); },
// onUncaughtError(error, info) { report(error, info, { caught: false }); },
// onRecoverableError(error) { console.warn('Recovered', error); }
// }).render(<App />);
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready