Guide
React error boundaries explained
Harbor Commerce shipped a redesigned checkout with embedded widgets for
shipping quotes, tax calculation, and loyalty points. A vendor API schema
change caused the shipping widget to call .map() on
undefined during render. React unmounted the entire checkout
tree — cart summary, payment form, and all — leaving shoppers
with a white screen and no path to pay. Support tickets spiked 340% in
two hours. The fix was not patching the vendor immediately (that took
days) but wrapping each widget in an error boundary so
one bad subtree could not collapse the revenue path. Shoppers saw a
“Shipping estimates temporarily unavailable” card with a retry
button while cart and Stripe checkout kept working. Conversion recovered
to 94% of baseline within the hour. Error boundaries are React's
built-in mechanism for isolating render-time failures: they catch
JavaScript errors in child component trees, log them, and display a
fallback UI instead of crashing the whole app. This guide covers what
boundaries catch (and what they do not), class-component and library
patterns, placement strategy, reset and recovery flows, pairing with
Suspense
and Next.js error.tsx, a Harbor Commerce worked example, a
placement decision table, common pitfalls, and a production checklist.
What error boundaries catch
React walks the component tree during render and commit. If any child
throws during render, in a lifecycle
method, or in a constructor of a descendant,
the nearest error boundary above it intercepts the throw. The boundary
switches to fallback UI and calls componentDidCatch (or
the library equivalent) so you can log the error and component stack to
Sentry, Datadog, or your own endpoint.
Error boundaries do not catch:
- Errors inside event handlers (use try/catch there)
- Errors in async code after await (use
.catch()or try/catch in the async function) - Errors thrown in the boundary itself (only a parent boundary can catch those)
- Errors during server-side rendering on the server process (handle with framework error pages)
- Errors in hooks called outside a child that threw (the throw must originate in a descendant's render path)
The distinction matters in production: a failed onClick
handler will not trigger a boundary; you must handle it locally or
route to global error state. Boundaries are for structural
failures that would otherwise unmount large subtrees.
Class component API
React has no hook equivalent for error boundaries as of React 19.
The canonical implementation is still a class component with
getDerivedStateFromError and componentDidCatch:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logToService(error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
getDerivedStateFromError must be pure — no side
effects. Use componentDidCatch for logging, analytics,
and session replay triggers. Pass a resetKeys prop (not
built-in; you implement it) to clear hasError when route
or data props change so navigation automatically retries.
The react-error-boundary library
Most teams use
function components
exclusively and reach for the react-error-boundary package
instead of maintaining class boilerplate. It exposes
<ErrorBoundary> with FallbackComponent,
onError, onReset, and resetKeys
out of the box:
import { ErrorBoundary } from 'react-error-boundary';
function WidgetFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
<ErrorBoundary
FallbackComponent={WidgetFallback}
onError={(error, info) => Sentry.captureException(error, { extra: info })}
onReset={() => queryClient.invalidateQueries(['shipping'])}
resetKeys={[cartId, locale]}
>
<ShippingWidget />
</ErrorBoundary>
useErrorBoundary() from the same package lets event
handlers and async callbacks re-throw into the nearest boundary when
you want centralized fallback UI for failures that boundaries normally
miss.
Placement strategy: granular vs route-level
A single top-level boundary around <App /> is better
than nothing — it prevents a full white screen — but it
still replaces your entire UI with one generic error page. Production
apps layer boundaries at three levels:
- Route shell. One boundary per route or layout segment so a broken dashboard widget does not kill navigation.
- Feature islands. Third-party embeds, charts, rich text editors, and experimental features each get their own boundary.
- Root safety net. An outer boundary at the app root catches anything that slips through and offers “Reload app”.
Harbor Commerce placed boundaries around shipping, tax, and loyalty
widgets individually, plus a route-level boundary on the checkout
layout and a root boundary in main.tsx. When the shipping
vendor broke, only the shipping card showed fallback; payment and cart
summary never unmounted.
Fallback UI design
Fallback components should be self-contained: they must not depend on context or data from the subtree that crashed. Good fallbacks include:
- A short, human-readable message (not raw stack traces in production)
- A retry action wired to
resetErrorBoundary - An alternative path (e.g. “Enter shipping manually”)
role="alert"for screen readers
Avoid fallbacks that import the same broken module or re-fetch the same poisoned data without invalidating cache first. Pair retry with cache invalidation or a fresh query key so the second attempt does not immediately re-render the same throw.
Next.js App Router: error.tsx vs boundaries
Next.js 13+ App Router provides error.tsx and
global-error.tsx as route-segment error boundaries. They
work like React boundaries but run on the server boundary between
segments and automatically reset when navigation changes the segment.
Use error.tsx for page-level failures; use client-side
<ErrorBoundary> inside client components for
widget-level isolation within a single page. They complement each other
— neither replaces the other.
For streaming SSR with Suspense, wrap Suspense boundaries inside error boundaries (error outside, Suspense inside) so a failed lazy import triggers fallback UI while sibling Suspense regions keep streaming.
Harbor Commerce checkout refactor
Before boundaries, checkout was one React tree: cart, widgets, and
Stripe Elements in a single <CheckoutPage />. Any
render throw anywhere unmounted Stripe and lost in-progress payment
intents. After refactor:
- Extracted shipping, tax, and loyalty into lazy-loaded client components behind individual
<ErrorBoundary>wrappers. - Added
resetKeys={[cartId, shippingZip]}so changing address auto-retries without a button click. - Wired
onErrorto Sentry with widget name tag and cart value for prioritization. - Added manual shipping fallback form rendered when boundary state is active (feature flag).
- Kept Stripe Elements outside all widget boundaries in a stable parent that never imports vendor code.
When the vendor schema broke again six weeks later, only the shipping card fell back; completed orders continued flowing. Mean time to detect dropped from shopper reports (median 18 minutes) to Sentry alert (under 2 minutes).
Placement decision table
| Scenario | Recommended approach |
|---|---|
| Third-party embed or iframe wrapper | Dedicated boundary per embed; never share with payment UI |
| Entire route may fail (bad server props) | Next.js error.tsx or route-level boundary |
| Lazy-loaded chart or map library | Boundary outside Suspense; fallback shows skeleton replacement |
| Event handler failure | try/catch locally; optional useErrorBoundary().showBoundary |
| Async fetch error | Query error state (TanStack Query) + boundary only if render throws on bad data |
| Experimental / beta feature | Boundary + feature flag kill switch independent of error state |
Common pitfalls
- Expecting boundaries to catch onClick errors. Wrap handler bodies in try/catch or use
showBoundary. - Fallback imports the broken component. Keep fallbacks in separate files with no shared dependency on the failing module.
- No reset path. Users stuck on error UI until full page reload; always offer retry or navigation.
- Logging only in development. Production boundaries without
componentDidCatch/onErrorleave you blind. - Boundary around everything including nav. One failure hides your entire chrome; scope tighter.
- Swallowing errors silently. Fallback UI without telemetry means repeat incidents go unnoticed.
- Throwing in getDerivedStateFromError. Must stay pure; side effects belong in
componentDidCatch. - Testing only happy path. Add tests that assert fallback renders when child throws.
Production checklist
- Audit render paths for third-party code, lazy imports, and JSON parsing in JSX.
- Place boundaries around each third-party widget island; keep payment/auth outside.
- Implement fallback with retry,
role="alert", and non-technical copy. - Wire
onError/componentDidCatchto observability with component tags. - Add
resetKeysfor route and identity changes that should auto-retry. - Pair lazy routes: ErrorBoundary wrapping Suspense, not the reverse.
- Add Next.js
error.tsxper major route segment if using App Router. - Write unit tests: render child that throws; assert fallback and retry behavior.
- Run chaos test in staging: inject throw in widget; verify checkout/payment survives.
- Document boundary map in README so new features land in the right shell.
Key takeaways
- Boundaries catch render-time throws in descendants — not event handlers, async code, or server errors.
- Granular placement keeps revenue paths alive when one widget fails.
- react-error-boundary is the practical choice for function-component codebases.
- Fallback + retry + logging is the minimum viable production pattern.
- Test failure paths — boundaries you never trigger in CI provide false confidence.
Related reading
- React fundamentals explained — components, hooks, and the render cycle boundaries protect
- React Suspense explained — loading boundaries paired with error boundaries
- Optimistic UI updates explained — rollback patterns when mutations fail
- Playwright E2E testing explained — browser tests for fallback and recovery flows