The Best Practices for React Error Boundaries
By Suki Watanabe |
Error boundaries are the silent guards of a React app. When a component tree snaps under an unhandled exception, these class-based wrappers catch the shrapnel before it hits the user. Without them, one broken widget can crater the entire UI. With them, you show a fallback, log the failure, and keep the rest of the app alive. After years of debugging production React apps, I’ve distilled a set of practices that separate a flimsy error boundary from one that actually holds up under pressure. This isn’t theory—it’s what works when your JavaScript throws a tantrum at 3 a.m.
React 16 introduced error boundaries to catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. They don’t catch everything—async code, event handlers, and server-side rendering errors slip through—but they’re your first line of defense for synchronous render-phase explosions. The trick is knowing where to place them, how to shape their fallback UI, and when to let them reset. Let’s break down the essentials.

1. Wrap at the Correct Granularity
A common mistake is slapping a single error boundary around the entire app. It works—until a minor error in the sidebar takes down the whole dashboard. Instead, think in terms of feature isolation. Wrap standalone sections: the sidebar, the main content area, a third-party widget. This keeps failures localized. If the chat panel crashes, the user can still browse settings. I typically place boundaries just above each route-level component and then add finer-grained ones around risky dynamic content—lists, forms, and any component that pulls in data with uncertain shape.
Where to Avoid Boundaries
Don’t wrap every single component. That adds unnecessary overhead and fragments your fallback UI into a patchwork of error messages. Also, avoid nesting boundaries too deeply if the child’s error isn’t recoverable—sometimes letting the parent boundary handle it gives a cleaner user experience. Test failure modes explicitly: throw an error in a component and see what disappears. If too much vanishes, your boundary is too high. If the user sees a confusing mix of working and broken UI, you might need to lift it up a level.
2. Craft Fallback UIs That Inform and Assist
A blank screen with “Something went wrong” is useless. The fallback should acknowledge the failure, but also give the user a path forward. Include a “Retry” button that resets the boundary’s state, and if possible, show context about what failed without leaking technical details. For example, “We couldn’t load your messages right now” beats “Uncaught TypeError: Cannot read properties of undefined.”
Make the fallback visually consistent with your app. A styled card with the same design tokens reduces the jarring feeling of a crash. For critical flows—like checkout or data entry—consider preserving partial state. If a form boundary catches an error, you can store the entered data in a ref and rehydrate the form on retry. This prevents users from losing their progress and wanting to throw their device out the window.
ErrorBoundary Component Structure
class ProductListBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to your error tracking service
console.error('Product list crash:', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="fallback-card">
<h2>Product listing unavailable</h2>
<p>We ran into a problem loading products. Check your connection or try again.</p>
<button onClick={this.handleRetry}>Retry</button>
</div>
);
}
return this.props.children;
}
}
Notice the retry logic resets the error state, causing a remount of the children. This is often enough to recover from transient failures. For more persistent issues, you might need a deeper investigation into the root cause.

3. Pair with Monitoring—Don’t Just Catch, React
An error boundary that silently swallows exceptions is a debugging nightmare. Every boundary must report errors somewhere actionable. Use componentDidCatch to send details to a service like Sentry, Datadog, or even a custom logging endpoint. Include the error message, stack trace, and any component-specific metadata: the boundary name, current route, and relevant props or state.
Standardize your logging. Create a base ErrorBoundary class that handles the reporting, then extend it for specific contexts. This avoids duplicating logging logic across twenty boundaries. Here’s a minimal pattern:
class BaseErrorBoundary extends React.Component {
logError(error, errorInfo) {
// Override in subclasses
}
componentDidCatch(error, errorInfo) {
this.logError(error, errorInfo);
}
}
class CheckoutErrorBoundary extends BaseErrorBoundary {
logError(error, errorInfo) {
reportToService('checkout', error, errorInfo, { userId: this.props.userId });
}
// ...fallback UI
}
Don’t forget about error severity. Not all boundary trips are equal. A crash in the footer is less urgent than one in the payment form. Tag your reports with severity levels so your on-call team knows when to drop everything.
4. Handle Async and Event Errors Gracefully
Error boundaries don’t catch errors inside event handlers or async operations like fetch calls. You need to handle those yourself. Wrap async logic in try/catch blocks and use React state to reflect errors in the UI. For event handlers, adopt a pattern that sets an error state instead of trusting the boundary to save you.
const [error, setError] = useState(null);
const handleSubmit = async () => {
try {
await submitForm(data);
} catch (err) {
setError(err);
}
};
if (error) {
return <ErrorDisplay message="Submission failed" onRetry={() => setError(null)} />;
}
This local error handling works alongside boundaries. Think of boundaries as a safety net for unexpected render errors, while expected async failures get explicit UI treatment. Combining both gives your app layered resilience.

5. Test Boundaries Under Real Failure Conditions
You can’t trust an error boundary until you’ve seen it work. Write tests that intentionally throw errors in child components and assert that the fallback appears and the rest of the app remains intact. Use React Testing Library or Enzyme to simulate these scenarios. Also, test the retry mechanism—verify that clicking “Retry” remounts the children and clears the error state.
Beyond unit tests, introduce chaos into your development builds. Temporarily modify a component to throw on certain conditions, like when a prop is missing. Navigate the app and see how boundaries respond. This live-fire testing reveals gaps you won’t find in isolated unit tests, like z-index conflicts with modals or state synchronization issues.
Common Boundary Pitfalls
- Swallowing errors without logging: The boundary catches, but nothing tells you why. Always log.
- Forgetting that boundaries reset on key changes: If you use a
keyprop to force remounting, the boundary state resets. Use this intentionally for recovery. - Overloading fallback UI with complex logic: The fallback should be simple. If it can also error, you’re in a recursion nightmare.
- Ignoring server-side rendering: Boundaries don’t work server-side. Plan for client-side hydration failures with checks on
typeof window.
6. Evolve Boundaries as Your App Grows
Your first error boundary won’t be your last. As features multiply, revisit your boundary strategy. A new WebSocket-driven real-time panel might deserve its own boundary with a specialized fallback that shows connection status. A complex form with multiple steps might need boundaries around each step to prevent losing all progress on a single crash.
Consider implementing a boundary hierarchy that mirrors your component architecture. Top-level boundaries catch broad failures; nested ones handle feature-specific hiccups. Document boundaries in your component catalog so every developer knows where they live and what they cover. This prevents accidental removal during refactors.
FAQ: Error Boundaries in Practice
Why don’t error boundaries catch errors in event handlers?
React’s rendering cycle is separate from event handling. An error in a click handler doesn’t occur during rendering, so it’s outside the boundary’s scope. You need to handle those errors locally with try/catch and state updates. Think of boundaries as protection for declarative UI, not imperative user interactions.
Can I use error boundaries with functional components?
Not directly. Error boundaries require componentDidCatch or getDerivedStateFromError, which are class component lifecycle methods. There’s no hook equivalent yet. You can wrap a functional tree with a class-based boundary, or use a package like react-error-boundary that provides a hook-based API while using a class under the hood.
Should I reset error boundaries automatically after a failure?
Automatic retries can be useful for transient issues, like network glitches, but they can also create endless loops if the error is persistent. A common approach is to offer a manual “Retry” button and, optionally, an automatic retry with exponential backoff for specific scenarios (e.g., lazy-loaded module failures). Always cap the number of automatic retries to avoid hammering a broken endpoint.