Why Your React App Falls Over (And How to Catch It)
We’ve all been there. A user clicks a button, a component somewhere deep in the tree throws, and the whole page goes white. No warning, no fallback—just a blank stare and a cryptic console trace. That’s React’s default when an unhandled error bubbles up. Since version 16, we’ve had a built-in safety net called Error Boundaries. But most devs either ignore them or bolt on something that barely works. Let’s change that.

What an Error Boundary Actually Does
An Error Boundary is a class component that uses static getDerivedStateFromError() or componentDidCatch()—ideally both. It catches JavaScript errors in its child tree during rendering, lifecycle methods, and constructors, then swaps in a fallback UI instead of letting the whole app unmount. Think of it as a try/catch block, but for React’s rendering pipeline.
Here’s the catch: Error Boundaries won’t catch errors inside event handlers, async code (like setTimeout or fetch), server-side rendering, or errors thrown in the boundary itself. For those, you still need plain old JavaScript error handling.
The Bare-Minimum Error Boundary
If you want something that just works without ceremony, start here. This component catches rendering errors and shows a simple fallback. No fluff.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Wrap any component that might throw with <ErrorBoundary>. If a child explodes, the boundary catches it, flips hasError to true, and renders the fallback. The rest of the app keeps humming along.
Where to Place Boundaries: The Granularity Question
Wrapping the entire app in a single Error Boundary is a classic blunder. If the top-level boundary catches an error, the whole UI gets replaced by a fallback. That’s barely better than a white screen. Instead, think in terms of isolated feature areas. Each major section—sidebar, dashboard widgets, product detail, comment thread—should have its own boundary. When a widget fails, only that widget shows the fallback. The rest of the app stays interactive.
This approach also makes debugging less painful. A granular boundary can log which specific component tree failed, making it easier to trace the root cause. Pair this with a monitoring service like Sentry or LogRocket, and you’ll have a clear picture of production errors.

Designing Fallback UIs That Don’t Suck
A generic “Something went wrong” message is lazy. Users don’t care about your stack trace. They care about getting back to work. Your fallback UI should be context-aware. If a product card fails, show a placeholder card with a “Retry” button. If a sidebar widget crashes, collapse it gracefully and offer a manual reload option. The goal is to preserve as much functionality as possible.
Here’s a pattern I use: pass a fallback prop to the Error Boundary. This lets each usage define its own recovery UI. For a data table, the fallback might be a skeleton loader with a “Refresh data” button. For a comment section, it could be a muted panel that says “Comments temporarily unavailable.”
function DataTableFallback({ onRetry }) {
return (
<div className="error-state">
<p>We couldn’t load this table.</p>
<button onClick={onRetry}>Try again</button>
</div>
);
}
<ErrorBoundary fallback={<DataTableFallback onRetry={() => window.location.reload()} />}>
<DataTable />
</ErrorBoundary>
Resetting the Error State
Error Boundaries don’t automatically recover. Once hasError is true, the fallback stays until the boundary unmounts and remounts. To give users a way to retry without a full page reload, you can add a reset mechanism. Use a key prop on the boundary to force a remount when the user clicks “Retry.”
function App() {
const [widgetKey, setWidgetKey] = useState(0);
return (
<ErrorBoundary
key={widgetKey}
fallback={<WidgetFallback onRetry={() => setWidgetKey(k => k + 1)} />}
>
<Widget />
</ErrorBoundary>
);
}
Changing the key forces React to treat the Error Boundary and its children as a completely new tree, resetting the error state. This is the cleanest way to implement a retry without forcing a full page reload.
Error Boundaries and Event Handlers: The Missing Piece
Remember, Error Boundaries don’t catch errors inside event handlers. If a click handler throws, the component doesn’t unmount—but the user gets no feedback unless you handle it. The fix is straightforward: wrap risky event handler logic in a try/catch and update local state to show an inline error message. This keeps the component alive and gives the user a clear path to recover.
function RiskyButton() {
const [error, setError] = useState(null);
const handleClick = () => {
try {
// operation that might throw
performDangerousAction();
} catch (e) {
setError('Action failed. Please try again.');
}
};
if (error) return <div className="inline-error">{error}</div>;
return <button onClick={handleClick}>Perform Action</button>;
}
This pattern pairs well with Error Boundaries. The boundary catches rendering errors; the try/catch handles interaction errors. Together, they cover the two main failure modes in a React component.
Logging: Don’t Just Catch, Learn
Catching an error silently is almost as bad as not catching it at all. You need visibility into what broke and why. In componentDidCatch, you have access to the error object and the component stack trace. Send that data to your logging infrastructure—whether it’s a custom endpoint, Sentry, Datadog, or a simple analytics beacon. Include metadata like the current route, user ID, and any relevant props that might help reproduce the issue.
Be careful not to leak sensitive information. Sanitize props before logging, especially if they contain personal data. A good practice is to define a serialization function that strips or masks fields like email, token, or password.

Testing Error Boundaries: Don’t Skip This
Most teams test the happy path and ignore failure states. With Error Boundaries, you need to verify both that errors are caught and that the fallback UI renders correctly. Use React Testing Library or Enzyme to simulate a component crash and assert that the boundary displays the expected fallback.
Here’s a quick test pattern using React Testing Library:
import { render, screen } from '@testing-library/react';
function Bomb({ shouldThrow }) {
if (shouldThrow) throw new Error('Boom!');
return <div>All good</div>;
}
test('ErrorBoundary catches error and shows fallback', () => {
const fallback = <div>Fallback UI</div>;
const { rerender } = render(
<ErrorBoundary fallback={fallback}>
<Bomb shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('All good')).toBeInTheDocument();
rerender(
<ErrorBoundary fallback={fallback}>
<Bomb shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Fallback UI')).toBeInTheDocument();
expect(screen.queryByText('All good')).not.toBeInTheDocument();
});
Also test the reset mechanism. Simulate an error, click the retry button, and verify that the original component renders again. These tests prevent regressions when someone refactors the Error Boundary or the fallback UI.
Common Pitfalls and How to Avoid Them
1. Using Error Boundaries for async errors. If a useEffect fetch fails, the Error Boundary won’t catch it. You need local error state and a retry button inside the component itself. The boundary is only for synchronous rendering errors.
2. Catching errors you can’t recover from. If a critical provider (like a theme or auth context) throws, wrapping it in an Error Boundary might leave the app in an inconsistent state. Some errors should still crash the app—just make sure you log them before the crash.
3. Forgetting to log. An Error Boundary that silently swallows errors is a debugging nightmare. Always log, even if it’s just to console.error in development.
4. Overly broad boundaries. Wrapping the entire app in one boundary defeats the purpose. Be surgical. Wrap individual feature trees.
FAQ
Can I use Error Boundaries with functional components?
No. Error Boundaries require getDerivedStateFromError or componentDidCatch, which are class component lifecycle methods. There is no hook equivalent yet. You must write your Error Boundary as a class component, but you can wrap functional children with it.
Should I use multiple Error Boundaries or just one at the top?
Use multiple, granular boundaries. Wrap independent sections of your UI—sidebar, main content, individual widgets—so a failure in one doesn’t take down the others. This also gives you more precise error logging.
How do I handle errors in event handlers if Error Boundaries don’t catch them?
Use standard try/catch inside the handler and set local state to display an inline error message. This keeps the component mounted and gives the user a clear path to retry or recover.
Can I recover an Error Boundary without remounting?
Not directly. Once an Error Boundary catches an error, its hasError state is set to true. To reset it, you need to remount the boundary. The cleanest way is to change the key prop on the boundary, which forces React to treat it as a new component.
Wrapping Up
Error Boundaries aren’t a silver bullet, but they’re the closest thing React gives you to a crash guard. Use them surgically, design fallbacks that keep users productive, and always log what went wrong. Pair them with try/catch in event handlers and async flows, and you’ll have an app that degrades gracefully instead of imploding. The difference between a blank screen and a “This widget failed—click to reload” message is the difference between losing a user and keeping one.