← Back to blogReact

Error boundaries in production React: beyond the basics

March 18, 2024·7 min read·2 comments

Error boundaries are one of the most underused features in React. The concept is straightforward: a component that catches JavaScript errors in its child component tree, logs them, and renders a fallback UI instead of crashing the entire application.

Most tutorials show the basic implementation and stop. In production, you need more: error reporting, recovery mechanisms, and granular boundary placement.

The basic implementation

React error boundaries are class components. There's no hook equivalent as of React 19. The API uses two lifecycle methods:

class ErrorBoundary extends Component<Props, State> {
  state = { hasError: false, error: null as Error | null }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <DefaultErrorUI error={this.state.error} />
    }
    return this.props.children
  }
}

getDerivedStateFromError updates state to trigger the fallback UI. componentDidCatch is where you send the error to your reporting service.

Error reporting

The componentDidCatch method receives the error and an errorInfo object that contains componentStack, a string showing the component tree path where the error occurred. This is invaluable for debugging because it tells you which component failed, not just which JavaScript line threw.

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  reportError({
    error,
    componentStack: errorInfo.componentStack,
    url: window.location.href,
    timestamp: new Date().toISOString(),
    userId: getCurrentUserId(),
  })
}

Send this to your error reporting service (Sentry, Datadog, LogRocket, or a custom endpoint). The component stack is the single most useful piece of context for debugging React errors in production.

Recovery

A fallback UI that says "Something went wrong" with no way to recover is almost as bad as a crash. Users need a way to retry.

class ErrorBoundary extends Component<Props, State> {
  state = { hasError: false, error: null as Error | null }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    reportError({ error, componentStack: errorInfo.componentStack })
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null })
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Something went wrong.</p>
          <button onClick={this.handleRetry}>Try again</button>
        </div>
      )
    }
    return this.props.children
  }
}

The retry button resets the error state, which causes the children to re-render. If the error was transient (a network failure during render, a race condition), the retry may succeed. If the error is deterministic (a null reference, a missing property), the retry will fail immediately and the boundary will catch it again.

For deterministic errors, you need a different recovery path: navigating away, clearing cached data, or showing a more helpful message.

Granular placement

One error boundary at the root of your application isn't enough. It prevents the white screen of death, but it also takes down the entire application when a single widget fails.

Place error boundaries around independent sections of the UI:

function Dashboard() {
  return (
    <div>
      <ErrorBoundary fallback={<MetricsError />}>
        <MetricsGrid />
      </ErrorBoundary>
      <ErrorBoundary fallback={<ActivityError />}>
        <RecentActivity />
      </ErrorBoundary>
      <ErrorBoundary fallback={<ChartError />}>
        <AnalyticsChart />
      </ErrorBoundary>
    </div>
  )
}

If MetricsGrid throws, the other sections continue working. The user sees an error message in the metrics area and can still use the rest of the dashboard.

My rule: every independently meaningful section of the UI should have its own error boundary. A sidebar, a data table, a chart, a comment section. These are all boundaries.

What error boundaries don't catch

Error boundaries don't catch errors in:

  • Event handlers (use try/catch)
  • Async code (promises, setTimeout)
  • Server-side rendering
  • Errors in the error boundary itself

For event handlers and async code, I use a combination of try/catch and a global error handler:

function handleClick() {
  try {
    doSomethingRisky()
  } catch (error) {
    reportError({ error, context: 'handleClick' })
    showErrorToast('Action failed. Please try again.')
  }
}

The error boundary handles render-time errors. Try/catch handles runtime errors. Together they cover the majority of failure modes in a React application.

A reusable wrapper

In every project, I create a reusable ErrorBoundary component with sensible defaults and wrap it in a function component for a cleaner API:

interface Props {
  children: ReactNode
  fallback?: ReactNode
  onError?: (error: Error, errorInfo: ErrorInfo) => void
}

function AppErrorBoundary({ children, fallback, onError }: Props) {
  return (
    <ErrorBoundaryClass fallback={fallback} onError={onError}>
      {children}
    </ErrorBoundaryClass>
  )
}

This is one of the first things I add to a new React project, before routing, before state management, before styling. If the application can crash gracefully from day one, every subsequent bug is easier to handle.

RESPONSES
Ben AdebayoApr 5, 2024

We added granular error boundaries to our dashboard after reading this and caught three silent crashes in production that were previously taking down the entire page. The componentStack in error reports is genuinely useful for debugging.

Ananya ReddyApr 19, 2024

The retry mechanism is something most error boundary tutorials skip. Ours just showed 'Something went wrong' with no way to recover. Added the retry button and it handles transient network errors gracefully now.

Leave a response