Error Boundary Pattern in React
Master React Error Boundaries — catch render crashes, fallback UI, class component requirements, granular placement, retry with key reset, logging, and what boundaries cannot catch.
Introduction
The Error Boundary pattern catches JavaScript errors during rendering in a child subtree and replaces the crashed UI with a fallback — instead of unmounting the entire app to a blank screen. Boundaries are React's structured recovery mechanism for component-tree failures.
This guide covers:
- Definition & purpose — catch, replace, fallback, prevent blank screens
- Default React behavior — how errors bubble without boundaries
- Implementation requirements — class components, lifecycle methods, state, props
- Application strategies — granular placement, sibling component rule
- Retry logic pattern —
retryKey+keyprop remount - Debugging & logging — error message, component stack, monitoring
- Limits & best practices — what boundaries do not catch
Quick index
| # | Section |
|---|---|
| 1 | Definition & purpose |
| 2 | Default React behavior |
| 3 | Implementation requirements |
| 4 | Application strategies |
| 5 | Retry logic pattern |
| 6 | Debugging & logging |
| 7 | Limits & best practices |
1. Definition & purpose
An Error Boundary is a React component that wraps a subtree and catches errors thrown during render, in lifecycle methods, or in constructors of children below it.
| Purpose | Meaning |
|---|---|
| Catches rendering errors | Intercepts throws in child render / lifecycle |
| Replaces crashed subtree | Only the wrapped region fails — not the whole app |
| Fallback UI provider | Shows recovery UI instead of a white screen |
| Prevents blank screens | Users stay in a working shell with a clear message |
App shell (always visible)
├── Sidebar ✓
├── ErrorBoundary
│ └── RevenueChart ✗ throws → fallback "Chart unavailable"
└── ActivityFeed ✓2. Default React behavior
Without boundaries, React treats the UI as one nested tree — an uncaught error in a leaf can tear down everything above it.
| Behavior | Consequence |
|---|---|
| UI as component tree | Errors originate in one node, affect ancestors |
| Child crash affects subtree | Failed child unmounts its branch |
| Errors bubble up | Propagate until caught or reach root |
| Unmanaged errors crash app | Root unmount → blank screen ("white screen of death") |
Without a boundary
function BrokenChart() {
const data = null as { values: number[] } | null
// Throws during render — no boundary → entire Dashboard unmounts
return <svg>{data!.values.map((v) => v)}</svg>
}
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<BrokenChart />
<ActivityFeed /> {/* never renders if chart throws */}
</div>
)
}With a boundary — sibling survives
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary fallback={<p>Chart failed to load.</p>}>
<BrokenChart />
</ErrorBoundary>
<ActivityFeed /> {/* still renders */}
</div>
)
}3. Implementation requirements
Error Boundaries must be class components — there is no useErrorBoundary hook in core React (libraries like react-error-boundary wrap this for ergonomics).
| Requirement | Detail |
|---|---|
| Class component | Functional components cannot be boundaries (yet) |
getDerivedStateFromError | Map error → state for fallback render |
componentDidCatch | Side effects — log, report to Sentry |
| State | hasError, optional error, optional retryKey |
| Props | children, fallback or render prop |
Full implementation
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
fallback?: ReactNode | ((error: Error, retry: () => void) => ReactNode)
onError?: (error: Error, info: ErrorInfo) => void
}
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
retryKey: number
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
error: null,
retryKey: 0,
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ErrorBoundary]', error.message, info.componentStack)
this.props.onError?.(error, info)
}
handleRetry = () => {
this.setState((prev) => ({
hasError: false,
error: null,
retryKey: prev.retryKey + 1,
}))
}
render() {
const { hasError, error, retryKey } = this.state
const { children, fallback } = this.props
if (hasError && error) {
if (typeof fallback === 'function') {
return fallback(error, this.handleRetry)
}
return fallback ?? <p role="alert">Something went wrong.</p>
}
// retryKey forces child remount on retry — see section 5
return <div key={retryKey}>{children}</div>
}
}react-error-boundary alternative
'use client'
import { ErrorBoundary } from 'react-error-boundary'
function ChartFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div role="alert">
<p>Chart error: {error.message}</p>
<button type="button" onClick={resetErrorBoundary}>
Try again
</button>
</div>
)
}
export function RevenueSection() {
return (
<ErrorBoundary FallbackComponent={ChartFallback} onError={logToSentry}>
<RevenueChart />
</ErrorBoundary>
)
}4. Application strategies
Multiple boundaries throughout the app beat one root wrapper.
| Strategy | Guidance |
|---|---|
| Avoid root-only | Root boundary = whole page fallback on any bug |
| Multiple boundaries OK | Wrap each risky island independently |
| Granular placement | Charts, third-party widgets, experiment flags |
| Sibling component rule | Independent siblings → separate boundaries |
Sibling component rule
| Relationship | Boundary strategy |
|---|---|
| Independent | Separate boundaries — one's crash does not hide the other |
| Interdependent | Shared boundary — if A needs B, wrap both together |
function AnalyticsDashboard() {
return (
<div className="grid grid-cols-2 gap-4">
{/* Independent widgets — separate boundaries */}
<ErrorBoundary fallback={<WidgetError label="Revenue" />}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError label="Users" />}>
<UserGrowthChart />
</ErrorBoundary>
{/* Interdependent pair — shared boundary */}
<ErrorBoundary fallback={<WidgetError label="Funnel" />}>
<FunnelFilters />
<FunnelChart />
</ErrorBoundary>
</div>
)
}
function WidgetError({ label }: { label: string }) {
return (
<div className="rounded border border-dashed p-6 text-center text-sm text-neutral-500">{label} unavailable</div>
)
}Layered boundaries — page + widget
function ReportsPage() {
return (
<ErrorBoundary fallback={<PageError />}>
<PageHeader />
<div className="grid gap-6">
<ErrorBoundary fallback={<p>Export panel failed.</p>}>
<ExportPanel />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Analytics failed.</p>}>
<AnalyticsGrid />
</ErrorBoundary>
</div>
</ErrorBoundary>
)
}5. Retry logic pattern
Let users recover without a full page refresh by resetting boundary state and remounting children with a new key.
| Step | Action |
|---|---|
handleRetry method | Clear hasError, increment retryKey |
Increment retryKey | State change triggers fresh child mount |
Force re-render via key | React discards failed subtree state |
| Avoid full refresh | Smoother UX than window.location.reload() |
'use client'
function ChartFallback({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4" role="alert">
<p className="font-semibold text-red-800">Could not load chart</p>
<p className="mt-1 text-sm text-red-600">{error.message}</p>
<button type="button" className="mt-3 rounded bg-red-800 px-3 py-1 text-white" onClick={onRetry}>
Try again
</button>
</div>
)
}
function RevenueWidget() {
return (
<ErrorBoundary
fallback={(error, retry) => <ChartFallback error={error} onRetry={retry} />}
onError={(error, info) => reportError({ error, componentStack: info.componentStack })}>
<RevenueChart />
</ErrorBoundary>
)
}Why retryKey matters
// Inside ErrorBoundary render — child remounts when retryKey changes
return <div key={retryKey}>{children}</div>Without a key change, a child with corrupted internal state might throw again immediately on retry. Incrementing key forces React to destroy and recreate the subtree — equivalent to a local soft refresh.
6. Debugging & logging
componentDidCatch receives everything needed for dev consoles and production monitoring.
| Data | Source | Use |
|---|---|---|
| Error message | error.message | User-facing copy, alert titles |
| Component stack | info.componentStack | Pinpoint which component failed |
| Console logging | componentDidCatch | Local dev debugging |
| Monitoring SDK | Sentry, Datadog, etc. | Production error tracking |
componentDidCatch(error: Error, info: ErrorInfo) {
if (process.env.NODE_ENV === 'development') {
console.group('[ErrorBoundary] Render crash')
console.error('Message:', error.message)
console.error('Stack:', error.stack)
console.error('Component stack:', info.componentStack)
console.groupEnd()
}
reportToSentry({
level: 'error',
message: error.message,
extra: {
componentStack: info.componentStack,
digest: error.digest, // Next.js may attach digest on server errors
},
})
}Structured error report helper
type ErrorReport = {
error: Error
componentStack: string
boundary: string
route?: string
}
export function reportError({ error, componentStack, boundary, route }: ErrorReport) {
// Replace with your monitoring provider
console.error(JSON.stringify({ boundary, route, message: error.message, componentStack }))
if (typeof window !== 'undefined' && 'Sentry' in window) {
// Sentry.captureException(error, { contexts: { react: { componentStack } } })
}
}7. Limits & best practices
What Error Boundaries do NOT catch
| Failure type | Correct handling |
|---|---|
| Event handler errors | try/catch inside handler |
Async code (setTimeout, fetch) | .catch() or try/catch in async fn |
| Server Components errors | Next.js error.tsx at route segment |
| Errors in the boundary itself | Parent boundary or route fallback |
| SSR during initial render | Framework error pages / error.tsx |
'use client'
function SaveButton() {
async function handleClick() {
try {
await saveProfile()
} catch (error) {
toast.error('Save failed') // boundary will NOT catch this
}
}
return (
<button type="button" onClick={handleClick}>
Save
</button>
)
}Next.js App Router — route-level recovery
// app/dashboard/error.tsx
'use client'
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div role="alert">
<h2>Dashboard failed to load</h2>
<p>{error.message}</p>
<button type="button" onClick={reset}>
Try again
</button>
</div>
)
}Summary
| Piece | Role |
|---|---|
| Error Boundary | Catches render errors in child subtree |
| Fallback UI | Replaces crashed region — app shell survives |
componentDidCatch | Log and report with component stack |
Retry + key | Remount subtree without page reload |
| Granular placement | Isolate widgets — not root-only |
Error Boundaries turn catastrophic white screens into localized, recoverable failures. Implement them as class components (or via react-error-boundary), place them around risky islands, and pair with handler try/catch and Next.js error.tsx for complete coverage.
Next reads: Facade Pattern for isolating risky data orchestration, Performance Guide (Part 1) for render optimization, and React Design Patterns for Suspense fallback boundaries.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime