Skip to content

Error Boundaries: Granularity and Recovery

Place error boundaries at route and widget granularity, recover without blank-screen religion or toast-shaped lies.

5 min read
Error Boundaries
React
Recovery UX
Route Errors
Frontend Resilience

Toasts handle expected failures you can narrate. Error boundaries handle render-time explosions: thrown errors during render, lifecycle, or constructors in the React tree beneath them.

The craft is granularity: too high and one widget blanks the app; too low and you drown in identical fallbacks with no shared reporting.

What boundaries catch (and what they do not)

Error boundaries catch errors in:

  • Render
  • Lifecycle methods
  • Constructors of child class components

They do not catch:

  • Event handlers (use try/catch or route to your notify helper)
  • Async code / fetch rejections (Query/onError, await try/catch)
  • SSR errors outside the boundary's model (framework route error UI)
  • Errors in the boundary itself
tsx
// src/errors/ReportErrorBoundary.tsx, illustrative class boundary import { Component, type ErrorInfo, type ReactNode } from 'react'; type Props = { children: ReactNode; fallback: ReactNode | ((opts: { error: Error; reset: () => void }) => ReactNode); onError?: (error: Error, info: ErrorInfo) => void; }; type State = { error: Error | null }; export class ReportErrorBoundary extends Component<Props, State> { state: State = { error: null }; static getDerivedStateFromError(error: Error): State { return { error }; } componentDidCatch(error: Error, info: ErrorInfo) { this.props.onError?.(error, info); // wire Sentry/report here: May's error-tracking post still applies } reset = () => this.setState({ error: null }); render() { const { error } = this.state; if (error) { const { fallback } = this.props; return typeof fallback === 'function' ? fallback({ error, reset: this.reset }) : fallback; } return this.props.children; } }

React 19 apps still use this class (or a thin wrapper). Framework route error.tsx / ErrorBoundary exports are the same idea at segment scope.

Granularity: route vs widget

PlacementIsolatesRisk
Root onlyNothing usefulOne chart blanks the whole shell
Route / segmentFeature crashesRight default for page-level failures
Widget (chart, editor, third-party)Untrusted / volatile UIBest for known hotspots
Every componentNoiseInconsistent copy; missed reporting
tsx
// app shell, illustrative nesting export function AppProviders({ children }: { children: React.ReactNode }) { return ( <ReportErrorBoundary fallback={<FullPageError />} onError={(e, info) => report(e, info)} > <Shell> <ReportErrorBoundary fallback={({ reset }) => ( <PanelError onRetry={reset} label="This panel crashed" /> )} > <Suspense fallback={<PanelSkeleton />}> <VolatileChart /> </Suspense> </ReportErrorBoundary> {children} </Shell> </ReportErrorBoundary> ); }

Rule: put a boundary where a failure has a meaningful fallback UI. If the fallback is identical to "blank main," you have not designed recovery - you have caught an exception.

Recovery UX without blank-screen religion

A useful fallback offers:

  1. What happened in human language (not only error.message in prod)
  2. What still works (nav, other panels) when the boundary is local
  3. Retry via reset when remounting might help
  4. Escape: link home / reload, when retry is unlikely
  5. Reporting already fired in componentDidCatch
tsx
function PanelError({ onRetry, label, }: { onRetry: () => void; label: string; }) { return ( <div role="alert" className="panel-error" > <p>{label}</p> <p>Try again. If it keeps failing, reload the page.</p> <button type="button" onClick={onRetry} > Retry </button> </div> ); }

Do not toast a render crash and leave a broken hole. Do not replace the entire product with a sad illustration if only the sidebar widget died.

Framework route errors

Next App Router error.tsx, Remix ErrorBoundary, and friends are segment boundaries. Use them for route-owned failures. Keep widget boundaries for embeds and experimental panels inside an otherwise healthy page.

Align copy and reporting between framework fallbacks and your shared ReportErrorBoundary so users do not see three visual languages for "something broke."

Failure modes

Root-only boundary. A third-party script in a widget takes down billing.

Swallowing errors with an empty fallback. You "handled" it by lying.

Retry without reset. Calling setState elsewhere does not clear the boundary; expose reset or remount via key.

Treating Query errors as boundary errors. Network failures are expected - render isError UI. Boundaries are for the unexpected throw during render.

Inventing uptime claims. I will not quote "99.9% recovered" without telemetry. The qualitative win is blast-radius control you can show in the tree.

Boundaries vs toasts vs inline errors

Keep the three channels honest:

ChannelFor
Inline / form errorsExpected validation and field-level API mapping
ToastsNon-blocking outcomes while the view stays usable
Error boundariesUnexpected render failures, recover the tree

If you toast a render crash, you still have a broken React subtree. If you boundary-catch a 400 from fetch inside render by throwing on purpose, you turn expected HTTP into a scary full-panel fallback. Match the channel to the failure class.

Continuity

Variants, class composition, and polymorphism are the next UI systems topics. Those posts stay draft-only until Brand Lead and owner publish path.

Takeaway

Error boundaries are blast-radius tools: route for pages, widget for volatile islands, and recovery UI that explains, retries, and reports, not a blank-screen cult.