Implementing React Error Boundaries for Production
React’s declarative rendering model creates a specific failure mode that does not exist in imperative code: a thrown exception inside a component’s render method unwinds the entire React tree and leaves the screen blank. Without an error boundary in place, a single bad prop value in a deeply nested widget can take down a checkout flow or a dashboard that has nothing to do with that widget. This guide covers every layer of a production-ready boundary strategy — from the raw class-component API through the react-error-boundary library, telemetry wiring, async bridging, and reset logic — as part of the broader Core JavaScript Error Handling & Boundaries discipline. If your application also runs non-React code, pair the patterns here with Handling Unhandled Promise Rejections in Modern JS and Mastering window.onerror and Global Event Listeners for full-spectrum coverage.
After working through this guide you will be able to:
- Write a class-based
ErrorBoundarythat correctly separates state capture from side-effect logging - Replace boilerplate with
react-error-boundaryand useresetKeysto auto-recover on navigation - Wire
componentDidCatchto an observability backend so every production failure creates an actionable alert - Bridge async errors into the synchronous render cycle so boundaries can catch them
- Design fallback UI that maintains layout stability and keyboard accessibility
Problem Framing & Symptom Identification
React renders components synchronously during a commit phase. When any component in the tree throws an uncaught exception during rendering, React propagates that exception up the fiber tree. Without a boundary to catch it, React unmounts the entire root and the DOM goes blank — or, in development mode, React overlays an error dialog that disappears in production builds, hiding the failure entirely from the user.
The symptoms that indicate you need an error boundary strategy:
- A JavaScript exception in a third-party widget causes the whole page to go white with no visible error in the browser console for end users
- Sentry or Datadog shows uncaught render errors but the
componentStackis missing, making triage impossible - A failed network response that propagates as a thrown error during render takes down unrelated UI panels
- After deploying a new release, user sessions end abruptly with no recovery path; users must do a full page reload
This unmount-on-error behavior is deliberate rather than a bug. Before React 16, an uncaught exception during render left the fiber tree in a corrupted, half-updated state: event handlers would fire against stale props and the interface could present impossible combinations of data. React 16 changed the contract so that any error reaching the root without being caught unmounts the whole tree, on the reasoning that a blank screen is a safer failure than a subtly wrong one that silently corrupts what the user is editing. An error boundary is the mechanism that lets you opt a specific subtree out of that all-or-nothing default — replacing the destructive unmount with a controlled fallback while the rest of the application keeps rendering. The diagram below contrasts the two outcomes for the same thrown error.
Error boundaries do not catch every category of JavaScript error. They are specifically designed for errors thrown during rendering, in lifecycle methods, and in constructors of class components. Errors thrown inside event handlers, setTimeout, Promise chains, or server-side rendering require different handling patterns — the async bridging section below covers how to route those into a boundary.
Prerequisites & Environment Setup
React version: Error boundaries require React 16.2 or later. The react-error-boundary library requires React 16.13+ for full resetKeys support.
TypeScript: If your project uses TypeScript, @types/react includes the ErrorInfo type used in componentDidCatch.
Install the recommended library:
npm install react-error-boundary@^4.0
# or
yarn add react-error-boundary@^4.0
If you are also setting up source map upload so that stack traces are readable in production, see Configuring Webpack for Production Source Maps before wiring telemetry — unreadable minified stacks make the telemetry step below nearly useless.
Your observability SDK (Sentry, Datadog, or a custom endpoint) should already be initialized before any React components mount. Initialize it in your application entry point, before ReactDOM.createRoot.
Step-by-Step Implementation
1. Write the basic class-based ErrorBoundary
React’s error boundary API is class-only. You need two lifecycle methods: getDerivedStateFromError (static, runs synchronously, must return state) and componentDidCatch (instance, runs after the commit, appropriate for side effects).
// src/components/ErrorBoundary.jsx
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// hasError gates the render branch; error is passed to the fallback
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// React calls this synchronously during the render phase.
// Return new state — do NOT produce side effects here.
return { hasError: true, error };
}
componentDidCatch(error, info) {
// info.componentStack is the React fiber stack, not the JS call stack.
// Side effects (logging, metrics) belong here, not in getDerivedStateFromError.
console.error('[ErrorBoundary]', error, info.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
// Render the fallback, passing reset so the UI can offer a retry button
return (
<FallbackUI
error={this.state.error}
onReset={this.handleReset}
/>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
The two-method split is not arbitrary. getDerivedStateFromError runs during React’s render phase, which React may invoke multiple times and even throw away under concurrent rendering, so it must be a pure function of the error with no side effects. componentDidCatch runs during the commit phase, after React has committed to showing the fallback, which makes it the only safe place to log, increment a metric, or fire a network request. Putting a telemetry call in the static method instead would double-report every error in React 18 Strict Mode, where render-phase functions are intentionally invoked twice in development to surface exactly this kind of impurity.
Wrap any subtree you want to isolate:
<ErrorBoundary>
<PaymentWidget />
</ErrorBoundary>
Place boundaries at logical UI seams — dashboard widgets, route-level components, third-party embeds — rather than at the app root. A single root boundary means a broken header takes down the checkout flow.
2. Replace boilerplate with react-error-boundary
The react-error-boundary package provides an ErrorBoundary component with props for FallbackComponent, onError, onReset, and resetKeys. resetKeys is the critical production feature: when any value in the array changes, the library automatically resets the boundary without user interaction — essential for route-based recovery.
// src/App.jsx
import { ErrorBoundary } from 'react-error-boundary';
import { useLocation } from 'react-router-dom';
import FallbackUI from './components/FallbackUI';
import { reportError } from './lib/telemetry';
function App() {
const location = useLocation();
return (
// resetKeys: boundary auto-resets when the user navigates to a new route
<ErrorBoundary
FallbackComponent={FallbackUI}
onError={(error, info) => reportError(error, info)}
resetKeys={[location.pathname]}
>
<Routes />
</ErrorBoundary>
);
}
For boundaries inside data-fetching components, the library also exports useErrorBoundary:
// Functional component that can trigger the nearest ErrorBoundary
import { useErrorBoundary } from 'react-error-boundary';
function DataWidget({ id }) {
const { showBoundary } = useErrorBoundary();
async function load() {
try {
const data = await fetchWidget(id);
setData(data);
} catch (err) {
// Routes the async error into the React boundary lifecycle
showBoundary(err);
}
}
// ...
}
3. Wire componentDidCatch to an observability backend
Raw console.error calls are not useful in production. Wire onError (or componentDidCatch in the class version) to your error tracking service with enough context that an alert is actionable without a reproduction.
// src/lib/telemetry.js
export function reportError(error, errorInfo) {
const payload = {
message: error.message,
stack: error.stack,
// componentStack is the React fiber tree, not the JS call stack
componentStack: errorInfo?.componentStack ?? null,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
// Include any user/session context your app tracks
sessionId: window.__SESSION_ID__ ?? 'unknown',
};
// Use sendBeacon so the request survives page unloads
if (navigator.sendBeacon) {
navigator.sendBeacon(
'/api/errors',
new Blob([JSON.stringify(payload)], { type: 'application/json' })
);
} else {
// Fallback for environments without sendBeacon
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {
// Swallow fetch errors — telemetry must never cause secondary failures
});
}
}
If you use Sentry, replace the manual payload with Sentry.captureException(error, { contexts: { react: { componentStack: errorInfo.componentStack } } }).
4. Bridge async errors into boundaries
Errors thrown inside useEffect, event handlers, or Promise chains are invisible to error boundaries by default. The canonical bridge pattern is to store the error in state during an async path, then throw it synchronously during the next render so the boundary can intercept it. The four stages below show the hop from an asynchronous catch back into React’s synchronous render path.
// src/hooks/useAsyncBoundary.js
import { useState, useCallback } from 'react';
/**
* Returns a function you can call in async code.
* The error is stored in state, then re-thrown during render
* so the nearest ErrorBoundary can catch it.
*/
export function useAsyncBoundary() {
const [, setError] = useState(null);
return useCallback((error) => {
setError(() => {
// Throwing inside a state updater function causes React
// to propagate the error through the boundary mechanism.
throw error;
});
}, []);
}
Usage in a component:
import { useAsyncBoundary } from '../hooks/useAsyncBoundary';
function OrderSummary({ orderId }) {
const throwToBoundary = useAsyncBoundary();
const [order, setOrder] = useState(null);
useEffect(() => {
fetchOrder(orderId)
.then(setOrder)
.catch(throwToBoundary); // async error now surfaces to the boundary
}, [orderId, throwToBoundary]);
if (!order) return <Skeleton />;
return <OrderDetails order={order} />;
}
5. Build the fallback UI component
The fallback must preserve layout stability so surrounding page chrome (navigation, footer) remains usable. Avoid full-page takeovers for widget-level boundaries.
// src/components/FallbackUI.jsx
export default function FallbackUI({ error, resetErrorBoundary }) {
return (
<div
role="alert"
aria-live="assertive"
style={{
padding: '1.5rem',
border: '1px solid #f0de93',
borderRadius: '6px',
background: '#fffdf2',
}}
>
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#222018' }}>
Something went wrong
</h2>
{/* Only show the message in development; never expose raw stack traces to users */}
{process.env.NODE_ENV === 'development' && (
<pre style={{ fontSize: '0.75rem', color: '#c0392b', whiteSpace: 'pre-wrap' }}>
{error?.message}
</pre>
)}
<button
onClick={resetErrorBoundary}
style={{ marginTop: '1rem' }}
>
Try again
</button>
</div>
);
}
For guidance on progressive disclosure of error details, accessible ARIA patterns, and skeleton-based degradation strategies, see How to Gracefully Degrade UI on Component Failure.
Production Telemetry Integration
Beyond the basic reportError function in step 3, a production telemetry setup needs three additional properties to be useful:
React component stack vs. JS call stack. The info.componentStack argument to componentDidCatch is a React-specific fiber tree trace — it shows which component hierarchy led to the crash, but it does not contain file paths or line numbers from your source. The JS error.stack contains those, but in a minified build they point to mangled names and bundle line numbers. You need both in your payload, and you need source maps uploaded to your observability platform to make the JS stack readable.
Release tagging. Include the current deployment release hash in every error payload. Most CI/CD systems expose this as an environment variable (VITE_RELEASE, NEXT_PUBLIC_RELEASE, etc.). This lets you filter errors by release and correlate a spike with a specific deploy.
User and interaction context. A stack trace tells you what broke but rarely why. Attach the last few user interactions — route transitions, button clicks, failed fetches — as breadcrumbs on the payload, plus a coarse user identifier so you can gauge whether a spike hits one account or thousands. Keep that identifier coarse: a hashed user id, never an email or display name, so your error tracker does not quietly become a store of personal data subject to the same retention and access rules as your primary database.
Sampling. In high-traffic applications, boundary fires on the same error variant will flood your error tracker. Apply client-side sampling: record the error fingerprint in sessionStorage and skip reporting if the same fingerprint was reported in the last five minutes. Always report the first occurrence.
// Prevent flooding: skip duplicate reports within the same session
const REPORT_COOLDOWN_MS = 5 * 60 * 1000;
export function reportError(error, errorInfo) {
const key = `eb:${error.message}`;
const last = parseInt(sessionStorage.getItem(key) || '0', 10);
if (Date.now() - last < REPORT_COOLDOWN_MS) return;
sessionStorage.setItem(key, String(Date.now()));
// ... send payload
}
Verification & Testing
Unit testing with React Testing Library.
Suppress React’s default console.error output during boundary tests — React always logs caught errors in development mode, and the output clogs test reporters.
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from 'react-error-boundary';
function Bomb() {
throw new Error('test explosion');
}
test('renders fallback on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary FallbackComponent={({ error }) => <p>Error: {error.message}</p>}>
<Bomb />
</ErrorBoundary>
);
expect(screen.getByText('Error: test explosion')).toBeInTheDocument();
spy.mockRestore();
});
Assert the telemetry contract, not just the fallback. A boundary that renders its fallback but never reports is a silent regression that unit tests for visible output will happily pass. Pass a mock onError (or spy on your reportError function), trigger the boundary, and assert both that it was called exactly once and that the payload carries the fields your dashboards depend on — componentStack, release hash, and session id. Add a second test that fires the same error twice within the sampling cooldown and asserts only one report leaves the client; the deduplication logic in the telemetry layer is easy to break and impossible to notice in production until an incident floods the tracker.
Testing reset behavior. Render the boundary in error state, fire a user event on the retry button, then assert the boundary re-renders children (after ensuring the child no longer throws in the next render cycle, typically via a mocked flag). Cover the automatic path too: change a value in resetKeys between renders and assert the boundary clears without any user interaction, since route-driven recovery is the reset path most applications rely on in practice.
End-to-end testing. In Playwright or Cypress, deliberately trigger a component error by intercepting an API call and returning a malformed response, then assert that: the fallback UI is visible, the surrounding navigation is still interactive, and a request was made to your telemetry endpoint.
Verify in production-like builds. Always run at least one test against a production build (npm run build && npm run preview) before shipping boundary changes. The React production build behaves differently from development mode — in particular, it does not re-throw boundary-caught errors to the console, which can mask a mis-configured boundary that silently swallows errors.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
| Event handler errors not caught | Boundaries only intercept render-phase errors; onClick runs outside React’s render cycle |
Wrap handler bodies in try/catch; call showBoundary or the async bridge hook |
| Async errors not caught | Promise rejections and setTimeout callbacks run outside the synchronous render tree |
Use the useAsyncBoundary hook pattern or react-error-boundary’s useErrorBoundary |
| Boundary doesn’t catch its own render error | A boundary cannot catch errors thrown in its own render method |
Place a second boundary above; keep boundary render logic trivial — no data fetching |
| Production stack trace unreadable | Minified JS without source map upload | Upload .map files to your observability platform at deploy time; see the source map generation guide |
| Infinite reset loop | resetKeys value changes on every render (e.g., a new object reference) |
Stabilize resetKeys values with useMemo or primitive values like location.pathname |
| Hydration mismatch triggers boundary | Server and client render different HTML; React 18 treats some mismatches as errors | Audit SSR data serialization; use suppressHydrationWarning only for known-safe differences (timestamps, ads) |
| Boundary swallows errors silently | componentDidCatch present but render still returns children while hasError is true |
Gate the fallback on hasError; never re-render children after an error is captured until an explicit reset |
FAQ
Can I write a functional component as an error boundary?
No. React’s error boundary contract requires getDerivedStateFromError and componentDidCatch, which are class lifecycle methods. There is no functional equivalent in React itself. Use react-error-boundary’s ErrorBoundary component — it wraps the class internals and exposes a clean function-component API with hooks like useErrorBoundary for triggering boundaries from within functional trees.
How do I test that my boundary actually fires in CI?
Write a component that unconditionally throws (function Bomb() { throw new Error('test'); }), wrap it in your boundary, render with @testing-library/react, and assert the fallback appears. Suppress console.error in the test to avoid noise. For the telemetry side, pass a mock onError callback and assert it was called with the expected error. Run these tests against both development and production builds — the boundary behavior is slightly different in each.
Do error boundaries catch React hydration mismatches? Partially. React 18 introduced “recoverable hydration errors” — minor mismatches React can fix without throwing. Severe mismatches (different element types between server and client) do throw and will be caught by a boundary, but by that point the server-rendered HTML has already been discarded. The correct fix is to eliminate the mismatch at the source, not to rely on boundary recovery.
If a Promise rejection happens inside useEffect, will the boundary catch it?
Not automatically. useEffect callbacks run asynchronously after the commit and outside React’s error propagation path. Use the useAsyncBoundary hook from step 4 above: call throwToBoundary(err) in your .catch() handler, which schedules a state update that throws during the next render cycle and triggers the nearest boundary.
Should I use one big boundary or many small ones? Prefer many small ones. A single boundary at the application root turns every localized failure into a full-page outage, which is precisely the blast radius error boundaries exist to shrink. Place a boundary around each independently useful region — a sidebar, a data table, each dashboard card — so a crash in one leaves the others fully interactive. Then keep a single top-level boundary as a last-resort catch-all that renders a branded full-page fallback for errors that escape the inner boundaries. The inner boundaries should carry lighter, layout-preserving fallbacks; the outer one owns the rare total-failure case. This layering also improves telemetry: the depth at which a boundary fires tells you how localized the fault was, which is a useful triage signal on its own.
Related
- Core JavaScript Error Handling & Boundaries — parent overview covering the full error handling landscape
- Handling Unhandled Promise Rejections in Modern JS — companion guide on async rejection patterns
- Mastering window.onerror and Global Event Listeners — companion guide for errors outside React trees
- How to Gracefully Degrade UI on Component Failure — fallback UI design in depth
- Resetting React Error Boundaries on Route Change — detailed reset and navigation recovery patterns