The Best Practices for React Error Boundaries

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.

Developer reviewing code on a dual-monitor setup with React error logs visible
Spotting errors early in development prevents boundary breaches in production.

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.

Stylish error fallback UI on a mobile app screen with a retry button
A clear retry button turns a crash into a minor interruption.

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.

Developer analyzing error logs on a dashboard with multiple charts and alerts
Effective monitoring turns boundary catches into actionable alerts.

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 key prop 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.

React error boundaries are not a one-time setup. They’re living parts of your application that need monitoring, testing, and refinement. Start with strategic placement, clear fallbacks, and solid logging. Then iterate as you see real failures in the wild. When the next 3 a.m. bug hits, you’ll be glad you did.