How to Gracefully Degrade UI on Component Failure
When a React component throws an exception during render, the entire subtree below the failure point unmounts — unless an error boundary intercepts it first. This guide covers the exact patterns for designing fallback UIs that contain the blast radius, as part of Implementing React Error Boundaries for Production, one of the core techniques in Core JavaScript Error Handling & Boundaries.
Symptom / Trigger
The first visible sign is a blank white screen — the browser viewport empties entirely. In the DevTools console you will see React’s own error report followed by a component stack:
The above error occurred in the <UserDashboard> component:
at UserDashboard (http://localhost:3000/static/js/main.chunk.js:42:15)
at div
at App
React will try to recreate this component tree from scratch using the error boundary you provided, UserDashboard.
Uncaught Error: Cannot read properties of undefined (reading 'map')
at UserDashboard (main.chunk.js:42:15)
Without a boundary in place, React skips the recovery step and the message reads:
The above error occurred in the <UserDashboard> component.
Consider adding an error boundary to your tree to customize
error handling behavior.
At that point React unmounts the root, leaving the page completely empty. Navigation links, headers, and sidebars all disappear because they share the same React root.
Root Cause Explanation
React propagates render-phase exceptions up the component tree looking for the nearest error boundary. If none exists, the exception reaches the root and React unmounts the entire application. The following pattern is the most common trigger in production:
// No boundary anywhere in the tree
function App() {
return (
<div>
<Header />
{/* If UserDashboard throws, Header disappears too */}
<UserDashboard />
<Footer />
</div>
);
}
function UserDashboard() {
const { data } = useFetchMetrics(); // returns undefined on network error
return data.metrics.map(m => <MetricCard key={m.id} metric={m} />);
// ^^^^^^^ TypeError: Cannot read properties of undefined
}
The absence of a boundary is the structural flaw. <Header /> and <Footer /> are completely functional but they still vanish because React cannot surgically remove only <UserDashboard />.
Step-by-Step Fix
Step 1 — Wrap the failing widget in a granular error boundary
Use a class component directly or the react-error-boundary package. The boundary must wrap only the problematic widget, not the whole page.
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<div>
<Header />
<ErrorBoundary FallbackComponent={DashboardFallback}>
<UserDashboard />
</ErrorBoundary>
<Footer />
</div>
);
}
<Header /> and <Footer /> now remain mounted and interactive regardless of what <UserDashboard /> does. The boundary acts as a blast-radius limiter: only the subtree it wraps can be replaced by the fallback.
Step 2 — Design a skeleton fallback that preserves layout dimensions
A fallback that collapses to zero height causes layout shift for surrounding content. Mirror the approximate dimensions of the widget it replaces and include an aria-live region so screen readers announce the state change.
function DashboardFallback({ error, resetErrorBoundary }) {
return (
<section
aria-live="assertive"
aria-atomic="true"
role="region"
aria-label="Dashboard unavailable"
style={{ minHeight: 320, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
border: '1px dashed #e2e8f0', borderRadius: 8, padding: 24 }}
>
<p style={{ fontWeight: 600, color: '#c0392b', marginBottom: 8 }}>
Dashboard failed to load
</p>
<p style={{ color: '#625b45', fontSize: 14, marginBottom: 16 }}>
{error.message}
</p>
<button onClick={resetErrorBoundary} style={{ padding: '8px 20px' }}>
Try again
</button>
</section>
);
}
aria-live="assertive" ensures assistive technologies interrupt the user immediately — appropriate when a visible feature has disappeared.
Step 3 — Add exponential backoff retry logic to the fallback
Allow the user to retry but guard against runaway re-renders if the failure is deterministic. Track the attempt count outside the boundary using a key prop reset strategy combined with a counter ref.
import { useState, useCallback } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const BASE_DELAY_MS = 500;
function DashboardFallbackWithRetry({ error, resetErrorBoundary, attempt }) {
const delay = BASE_DELAY_MS * 2 ** attempt; // 500, 1000, 2000 …
const maxAttempts = 4;
const canRetry = attempt < maxAttempts;
return (
<section aria-live="assertive" role="region" aria-label="Dashboard unavailable"
style={{ minHeight: 320, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<p style={{ fontWeight: 600, color: '#c0392b' }}>Dashboard failed to load</p>
{canRetry ? (
<button
onClick={resetErrorBoundary}
style={{ marginTop: 12, padding: '8px 20px' }}
>
Retry (attempt {attempt + 1}/{maxAttempts}, wait {delay / 1000}s)
</button>
) : (
<p style={{ color: '#625b45', fontSize: 14 }}>
Too many failed attempts. Reload the page to continue.
</p>
)}
</section>
);
}
export function SafeDashboard() {
const [attempt, setAttempt] = useState(0);
const handleReset = useCallback(() => {
setAttempt(prev => prev + 1);
}, []);
return (
<ErrorBoundary
key={attempt} // remounts boundary subtree on reset
FallbackComponent={(props) => (
<DashboardFallbackWithRetry {...props} attempt={attempt} />
)}
onReset={handleReset}
>
<UserDashboard />
</ErrorBoundary>
);
}
The key={attempt} forces React to unmount and remount the entire boundary subtree on each retry, which is exactly the “fresh start” needed to re-run data fetching. Without it, React may keep stale props or state from the failed render.
Step 4 — Wire componentDidCatch to observability
componentDidCatch (or onError in react-error-boundary) receives errorInfo.componentStack, which is the string you need for source-map resolution. Send it to your observability pipeline immediately, before the fallback finishes rendering.
import * as Sentry from '@sentry/react';
function onErrorHandler(error, info) {
Sentry.withScope(scope => {
scope.setExtra('componentStack', info.componentStack);
scope.setTag('boundary', 'UserDashboard');
Sentry.captureException(error);
});
}
// Pass to react-error-boundary:
<ErrorBoundary FallbackComponent={DashboardFallback} onError={onErrorHandler}>
<UserDashboard />
</ErrorBoundary>
Never log only error.message. Always include componentStack — it is the only way to reconstruct which component chain led to the throw, especially after minification collapses display names.
Step 5 — Manage focus on fallback mount for accessibility
When the fallback renders, keyboard focus typically remains on whatever element the user last interacted with, which may no longer exist in the DOM. Move focus to the fallback container so screen reader users are oriented to the new state.
import { useEffect, useRef } from 'react';
function AccessibleFallback({ error, resetErrorBoundary }) {
const containerRef = useRef(null);
useEffect(() => {
// Defer until after paint so the element is actually visible
const id = requestAnimationFrame(() => {
containerRef.current?.focus();
});
return () => cancelAnimationFrame(id);
}, []);
return (
<section
ref={containerRef}
tabIndex={-1} // programmatically focusable
aria-live="assertive"
role="region"
aria-label="Dashboard unavailable"
style={{ minHeight: 320, outline: 'none', padding: 24 }}
>
<p style={{ fontWeight: 600, color: '#c0392b' }}>Dashboard failed to load</p>
<p style={{ color: '#625b45', fontSize: 14 }}>{error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</section>
);
}
tabIndex={-1} makes the container programmatically focusable without inserting it into the natural tab order. The outline: 'none' suppresses the default focus ring on the container while still ensuring the button inside remains visible on keyboard navigation.
Verification
Use React Testing Library with the @testing-library/jest-dom matchers to assert that the boundary renders the fallback instead of crashing the test suite. Silence React’s console error output in the test to keep the output readable.
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorBoundary } from 'react-error-boundary';
import { AccessibleFallback } from './AccessibleFallback';
function BrokenWidget() {
throw new Error('Simulated render failure');
}
beforeEach(() => {
// Suppress React's own error boundary console output in tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
console.error.mockRestore();
});
test('renders fallback and retry button when child throws', () => {
render(
<ErrorBoundary FallbackComponent={AccessibleFallback}>
<BrokenWidget />
</ErrorBoundary>
);
expect(screen.getByRole('region', { name: /dashboard unavailable/i }))
.toBeInTheDocument();
expect(screen.getByRole('button', { name: /try again/i }))
.toBeInTheDocument();
expect(screen.queryByText('Simulated render failure'))
.toBeInTheDocument();
});
test('calls resetErrorBoundary when retry button is clicked', () => {
const onReset = jest.fn();
render(
<ErrorBoundary FallbackComponent={AccessibleFallback} onReset={onReset}>
<BrokenWidget />
</ErrorBoundary>
);
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(onReset).toHaveBeenCalledTimes(1);
});
Both tests confirm: the fallback region is in the DOM, the retry button exists, and the reset callback fires on click — without the test itself crashing.
Edge Cases & Gotchas
-
Hydration mismatch causes silent boundary triggers in SSR. During server-side rendering, React will call
getDerivedStateFromErrorif the client HTML diverges from what the server sent, even if no true render error occurred. The fallback then replaces valid server-rendered markup on the client. Guard against this by keeping client-only state out of the initial render pass (useuseEffectto populate it) and by checkingtypeof window !== 'undefined'inside code that reads browser APIs. -
CSS-in-JS styles are lost when the fallback replaces the failed tree. Libraries like styled-components or Emotion inject styles into the document as each component mounts. When an error boundary swaps in a fallback component that was not part of the original render, those injected style tags may still be scoped to the failed component’s class hash. The fallback must carry its own inline styles or use global CSS classes that exist independently of the failed component.
-
Nested boundaries shadow outer boundaries. A deeply nested boundary will catch errors from its subtree before the outer boundary sees them. This is the intended behavior, but it becomes a gotcha when you expect a top-level observability handler to receive every error. Pass
onErrorto every boundary, not only the outermost one, or use a shared handler exported from your observability module. -
Server-side rendering with
renderToStringdoes not support boundaries.renderToStringdoes not honor error boundaries — any throw propagates synchronously and crashes the server-side render. UserenderToPipeableStream(React 18+), which does process boundaries on the server, or wrap the entirerenderToStringcall in atry/catchand serve a minimal HTML shell on failure.
FAQ
Why doesn’t the error boundary catch errors from useEffect or event handlers?
Error boundaries only intercept exceptions that occur during the React render phase — inside the render method of a class component or inside the function body of a function component. Errors thrown inside useEffect, event handlers (onClick, onChange, etc.), or setTimeout callbacks happen outside the render phase. For those, use try/catch blocks or attach a handler to window.addEventListener('error', ...) and window.addEventListener('unhandledrejection', ...). See Handling Unhandled Promise Rejections in Modern JS for async patterns.
Does this approach satisfy WCAG 2.1 accessibility requirements?
The pattern above satisfies WCAG 2.1 Success Criteria 4.1.3 (Status Messages) by using aria-live="assertive", and it addresses 2.4.3 (Focus Order) by programmatically moving focus to the fallback container on mount. To fully satisfy 1.4.1 (Use of Color) the error state must not rely on red color alone — include a text label or icon. Test with a screen reader (NVDA, VoiceOver) to verify that the live region announces before the retry button is reached by tabbing.
How do I get readable stack traces in production when component names are minified?
Minification collapses UserDashboard to a single character like e, making errorInfo.componentStack useless without source maps. Upload your .map files to your error-tracking service (Sentry, Datadog RUM, LogRocket) as part of your CI/CD deploy step. For Sentry this means running sentry-cli sourcemaps inject and sentry-cli sourcemaps upload after next build or webpack. Also set displayName on dynamically created components so the unminified name appears in error boundaries that run in development mode.