Error Boundaries: Production-Ready Resilience

React components are functions. When a function throws during render, React needs a policy: crash the whole app, or contain the failure and render something else. Error boundaries are that containment mechanism. They are implemented as class components because, at the time of writing, there is still no first-class function-component equivalent for getDerivedStateFromError and componentDidCatch in stable React—though the ecosystem wraps them so you rarely author the class by hand.

For TypeScript developers, the interesting work is not the class syntax; it is the typed contract between boundary, fallback, monitoring, and recovery.

A typed boundary with functional fallback

The following sketch shows explicit props and state types, a functional fallback for rich UI, optional telemetry in componentDidCatch, and a reset path so users can retry after transient failures.

interface ErrorBoundaryProps {
  children: React.ReactNode
  fallback?: React.ReactNode | ((error: Error, info: React.ErrorInfo) => React.ReactNode)
  onError?: (error: Error, info: React.ErrorInfo) => void
}

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
  errorInfo: React.ErrorInfo | null
}

class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false, error: null, errorInfo: null }

  static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    this.setState({ errorInfo: info })
    this.props.onError?.(error, info)
    reportError(error, { componentStack: info.componentStack })
  }

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

  render() {
    if (!this.state.hasError) return this.props.children

    const { fallback } = this.props
    if (typeof fallback === 'function') {
      return fallback(this.state.error!, this.state.errorInfo!)
    }
    return fallback ?? <DefaultErrorUI onReset={this.resetErrorBoundary} />
  }
}

reportError stands in for your real pipeline: Sentry, Datadog, OpenTelemetry, or an internal logger. The critical production detail is capturing componentStack, which turns minified bundles into actionable routes in your source maps.

Correlate errors with release versions and user/session identifiers in the same payload; otherwise you get beautiful stack traces that nobody can map to a deployment window. Many teams attach route name, feature flags, and experiment buckets so triage can answer whether a spike coincided with a flag flip without a meeting.

Reset keys and recovering without a full reload

A boundary that only shows “Something went wrong” is better than a white screen, but it is not done. Users often fix the underlying condition—network returns, they navigate away and back, or a sibling refetch clears bad cache—without wanting to lose client-side state for the entire app. resetErrorBoundary models that: after a successful refetch or route change, call reset so children mount fresh and re-run effects.

Libraries like react-error-boundary add a resetKeys array: when userId, pathname, or queryKey changes, the boundary resets automatically. That pattern prevents a stuck error UI after the world has clearly moved on. In TypeScript, type resetKeys as ReadonlyArray<unknown> or a tuple of primitives you control so accidental object identity churn does not silently defeat resets.

Layering with Suspense

Suspense handles waiting; error boundaries handle render throws. Keep those concerns separate by nesting them deliberately—often boundary on the outside so a suspended child still lives under the same error policy as its siblings.

<ErrorBoundary fallback={<ErrorUI />}>
  <Suspense fallback={<LoadingUI />}>
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>

If you invert the order without thinking, you can end up with fallbacks that flash in surprising sequences or boundaries that are too coarse to attribute failures. In large apps, feature-scoped boundaries matter more than the exact nesting order: checkout, editor canvas, and data grid each get their own net so a bug in one panel does not evacuate the shell.

What error boundaries do not catch

This list is worth memorizing because it is where production incidents come from:

  • Event handlers. Errors in onClick and friends do not propagate to an error boundary. Use try/catch or a structured error-handling utility inside the handler.
  • Asynchronous code such as bare setTimeout, unresolved Promise rejections, or callbacks that fire after a microtask. React’s newer concurrent and use-based data paths can surface some async failures into render, but you should still design explicit error states for promises: errorElement on routers, catch on route loaders, or error results from your data layer. Treat the boundary as a safety net for unexpected render throws, not a substitute for modeling failure in your fetch layer.
  • Server-side rendering errors are handled by your SSR framework, not by this client boundary.
  • Errors in the boundary itself. If the fallback throws, React cannot magically recover.

Framing boundaries as “render-phase safety nets” keeps expectations aligned with how React actually runs.

Production patterns beyond the demo class

Most teams adopt react-error-boundary rather than maintaining a bespoke class. It provides ErrorBoundary, reset keys, and patterns that integrate with function components while still using the underlying class mechanism React requires.

Operational practices that separate junior code from production-grade resilience:

  • Context-aware placement. A single root boundary is a last resort; intermediate boundaries preserve navigation chrome and isolate experimental modules.
  • Different UX per environment. Verbose stacks in development; friendly copy and support IDs in production.
  • Reset without reload. Expose resetErrorBoundary from your fallback UI after fixing upstream state (for example, clearing bad user input or refetching after a deployment).
  • Typed fallbacks. Model fallback props so you cannot pass a component that expects unrelated error shapes—especially when wrapping third-party widgets.

TypeScript ergonomics

Keep ErrorInfo from React’s types; do not redeclare componentStack as string | null | undefined inconsistently across your codebase. If you use a functional fallback, narrow errors carefully—some thrown values are not Error instances. A small normalizer in componentDidCatch improves logging fidelity.

Testing boundaries

Unit-test the fallback UI like any other component. For the boundary itself, trigger a child that throws conditionally—for example, a test double gated by props—assert the fallback renders, then change props or invoke reset and assert children return. Integration tests should verify that logging is invoked with a stack string: mock reportError or your telemetry client and assert call counts so refactors do not silently drop observability.

Closing the loop

Error boundaries are one layer in a defense-in-depth strategy: static analysis and tests prevent many bugs; boundaries catch the ones that slip through at render time; monitoring tells you what to fix; resets give users a path forward. Combined with Suspense at the right granularity, they let advanced React applications fail locally and recover intentionally—which is what “production-ready” means in practice.