React Error Boundaries: Catching Crashes Without Losing Your Mind
React error boundaries stop one bad component from taking down your whole app. Here's how to write them, place them, and build fallback UIs that don't embarrass you.
Why Your Whole App Shouldn't Die Because of One Bad Fetch
Here's the deal: before React 16, an unhandled render error would corrupt the virtual DOM and leave users staring at a blank screen with zero explanation. React 16 (released in 2017) changed that by introducing error boundaries — class components that can intercept render-phase exceptions and display a fallback instead. No more silent white screens of death.
The core idea is straightforward. You wrap a subtree of your component tree in an error boundary. If anything inside that subtree throws during rendering, a lifecycle method, or a constructor, the boundary catches it. Everything outside keeps running fine. Your sidebar doesn't care that the dashboard widget blew up.
Honestly, the thing most developers get wrong is placement. One global error boundary at the root of your app is better than nothing, but it means a crash in your comment section takes down your entire navigation too. Granular boundaries — one per major feature section — give you much better recovery behavior.
Worth noting: error boundaries only catch errors in the render cycle. They don't catch errors in event handlers, async code (setTimeout, fetch chains), or server-side rendering. Those still need try/catch or .catch() the old-fashioned way. Keep that distinction in your head or you'll spend an afternoon confused about why your boundary isn't firing.
Writing Your First Error Boundary (Yes, It's Still a Class Component)
Error boundaries have to be class components. That's not going to change anytime soon — the two lifecycle methods involved (getDerivedStateFromError and componentDidCatch) don't have hooks equivalents. You can wrap a class boundary in a function component for ergonomics, but the class is unavoidable.
Here's a minimal, production-ready boundary you can drop straight into your project:
``tsx
import { Component, ReactNode, ErrorInfo } from 'react';
interface Props {
fallback?: ReactNode;
children: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info);
// Send to Sentry, Datadog, whatever you're using:
console.error('[ErrorBoundary]', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
``
getDerivedStateFromError is a static method — it runs synchronously during rendering and you use it purely to update state so the fallback renders. componentDidCatch runs after the render and is where you do side effects like logging. Don't log in getDerivedStateFromError; keep it pure.
In practice, you want that onError callback prop. Passing your error reporting function (Sentry's captureException, for example) through the boundary means you get full stack traces with the React component stack attached — that info.componentStack string is gold when you're debugging a production crash at 2am.
Quick aside: TypeScript users, don't forget to type your State and Props interfaces properly. I've seen codebases where the error boundary was typed as any and the team lost the component stack info entirely because the logger was silently swallowing untyped objects.
Fallback UIs That Don't Make Users Want to Leave
The default <p>Something went wrong.</p> fallback is fine for getting started. Ship that to production and you deserve the angry support tickets. Your fallback UI is a UX moment — it can reassure the user, give them a next step, and preserve their trust in your product.
A good fallback for a data-heavy widget might look like:
``tsx
function WidgetError({ onRetry }: { onRetry: () => void }) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center">
<h3 className="font-semibold text-red-700">This section couldn't load</h3>
<p className="mt-1 text-sm text-red-500">
The rest of the page is working fine.
</p>
<button
onClick={onRetry}
className="mt-4 rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
>
Try again
</button>
</div>
);
}
``
Notice the retry button. To wire that up, your boundary needs to reset its own error state when the user asks it to. Add a resetError method to your class and pass it down through the fallback render prop:
``tsx
resetError = () => {
this.setState({ hasError: false, error: null });
};
// Then in render:
if (this.state.hasError) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback({ error: this.state.error, resetError: this.resetError });
}
return this.props.fallback ?? null;
}
``
If you're building components with strong visual identity — the kind you'd find in a glassmorphism or neobrutalism design system — your error states should match that visual language. A brutalist card component with a hard-shadow error state looks intentional. The default red-border box looks like you forgot to design it.
One more thing — don't put a full-page error state inside a small widget boundary. Match the fallback size to the component that failed. A 120px tall chart that throws shouldn't replace itself with a modal-sized error screen.
Composing Boundaries in Real Apps: Where to Put Them
Think in concentric zones. The outermost zone is your root-level boundary — it catches anything that somehow slipped through everything else and shows a full-page fallback. Then you have route-level boundaries (one per page), then feature-level boundaries (one per major section), and finally component-level boundaries for things that fetch external data or run risky third-party code.
In a Next.js 14+ App Router project, the file-based error.tsx convention handles route-level boundaries for you. Drop an error.tsx next to your page.tsx and Next wraps that segment automatically:
``tsx
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Dashboard failed to load</h2>
<button onClick={reset}>Retry</button>
</div>
);
}
``
That reset function from Next triggers a re-render attempt of the segment. It's clean, it's co-located with the route, and it costs you about 10 lines of code. Use it. For anything more granular — a chart, a comments feed, a live price ticker — reach for your manual class boundary.
Look, the most common mistake I see in production codebases is wrapping the *entire* application in a single boundary at the app root and calling it done. That's barely better than not having boundaries at all, because one crash anywhere still takes down your navigation, your header, your footer. Spend 20 minutes identifying the 4-5 sections of your UI that fetch independently, and put a boundary around each one.
Async Errors, Event Handlers, and Everything Boundaries Won't Catch
Error boundaries are synchronous render-phase tools. The moment you step outside a render — into a useEffect, an event handler, a setTimeout, a fetch Promise chain — you're on your own. That's not a bug in React's design; it's just a scope limitation worth being explicit about.
For event handlers, the fix is just try/catch:
``tsx
function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
const [error, setError] = useState<Error | null>(null);
async function handleClick() {
try {
await onSave();
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
}
return (
<>
{error && <p className="text-red-500">{error.message}</p>}
<button onClick={handleClick}>Save</button>
</>
)
}
``
For async errors inside useEffect, same deal — wrap your async function body in try/catch and handle the error in local state. If you want to deliberately *trigger* an error boundary from async code, there's a well-known trick: call a state setter inside the catch block in a way that propagates the error back into React's render cycle:
``tsx
const [, setError] = useState();
// Inside your async catch:
setError(() => { throw asyncError; });
``
This forces the error into the render phase where the boundary can intercept it. It's a bit of a hack, but it works reliably across React 18.x. Worth noting: React's upcoming improvements to async error handling may make this pattern unnecessary eventually, but for now it's the go-to approach.
That said, for most async UI errors you're better off using local error state. Only escalate to a boundary throw if the error represents a state so broken that the component literally can't render anything meaningful. A failed API call usually still lets you render a 'retry' button in the normal component tree — no boundary needed.
react-error-boundary: Just Use the Library
Writing your own boundary class is a good learning exercise. For production, the react-error-boundary library (currently at v4.x) gives you everything you'd build plus stuff you'd forget: a useErrorBoundary hook to imperatively trigger boundaries from anywhere, a withErrorBoundary HOC for legacy code, and a clean resetKeys prop that auto-resets the boundary when specified props change.
Install it in about 5 seconds:
``bash
npm install react-error-boundary
`
Then usage is dead simple:
`tsx
import { ErrorBoundary } from 'react-error-boundary';
function FallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Reset</button>
</div>
);
}
export function Dashboard() {
return (
<ErrorBoundary
FallbackComponent={FallbackComponent}
onError={(error, info) => logToSentry(error, info)}
resetKeys={[userId]} // resets when userId changes
>
<DashboardContent />
</ErrorBoundary>
);
}
``
The resetKeys feature alone is worth reaching for the library over rolling your own. If your user navigates to a different account or switches a filter, the boundary resets automatically without you wiring anything up. That 's the kind of thing you'd forget to build yourself until a user files a bug about it six months post-launch.
One thing the library doesn't do is decide where your boundaries should go or what your fallback UI should look like. That's still on you. If you're building a polished product, your fallback components deserve the same care as the rest of your UI — same fonts, same spacing, same 8px border-radius if that's what your design system uses.
Putting It Together: A Production-Ready Pattern
Here's the actual pattern I reach for in production apps. Three pieces: a thin boundary wrapper, a shared fallback component, and a centralized error reporter. Each piece stays small and replaceable.
// lib/error-reporting.ts
export function reportError(error: Error, info: { componentStack: string }) {
// Sentry, Datadog, whatever:
if (process.env.NODE_ENV === 'production') {
// Sentry.captureException(error, { extra: info });
} else {
console.error('[UI Error]', error.message, info.componentStack);
}
}
// components/SectionBoundary.tsx
import { ErrorBoundary } from 'react-error-boundary';
import { reportError } from '@/lib/error-reporting';
function SectionFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
<p className="text-sm text-zinc-500">This section ran into a problem.</p>
<button
onClick={resetErrorBoundary}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Try again
</button>
</div>
);
}
export function SectionBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary FallbackComponent={SectionFallback} onError={reportError}>
{children}
</ErrorBoundary>
);
}Drop <SectionBoundary> around any feature that fetches data, runs animations, or touches third-party code. You'll be surprised how rarely you need anything more complex than this. The 80/20 of error boundary work is just remembering to put them in the right places.
If your app uses a sophisticated visual system — say, glassmorphism components or styled cards from the Empire UI component library — make sure your SectionFallback visually fits. A frosted-glass card layout that falls back to a plain white div with a red border looks broken even when it's working correctly.
That's really the meta-point here: error handling is part of your UI, not separate from it. The boundary is the safety net. The fallback is the user experience. Both deserve engineering attention.
FAQ
No — error boundaries must be class components because they rely on getDerivedStateFromError and componentDidCatch, which have no hooks equivalents. You can wrap the class in a function component to make it more composable, but the class itself can't go away.
Boundaries only catch errors thrown during React's render cycle — not in event handlers, setTimeout, or Promise chains. Use try/catch for those. If you need to escalate an async error into a boundary, call setState(() => { throw asyncError; }) from a catch block.
Use the library for production. It's tiny, battle-tested, and has useful extras like resetKeys and useErrorBoundary that you'd have to rebuild yourself. Write your own only if you have a very specific constraint the library can't meet.
At minimum: one per major independently-loaded section (dashboard, sidebar, feed, etc.) plus one at the app root as a last resort. Avoid a single root-level boundary — it means any crash takes down your whole UI including navigation.