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 /
fetchrejections (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
| Placement | Isolates | Risk |
|---|---|---|
| Root only | Nothing useful | One chart blanks the whole shell |
| Route / segment | Feature crashes | Right default for page-level failures |
| Widget (chart, editor, third-party) | Untrusted / volatile UI | Best for known hotspots |
| Every component | Noise | Inconsistent 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:
- What happened in human language (not only
error.messagein prod) - What still works (nav, other panels) when the boundary is local
- Retry via
resetwhen remounting might help - Escape: link home / reload, when retry is unlikely
- Reporting already fired in
componentDidCatch
tsxfunction 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:
| Channel | For |
|---|---|
| Inline / form errors | Expected validation and field-level API mapping |
| Toasts | Non-blocking outcomes while the view stays usable |
| Error boundaries | Unexpected 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.