DevLift
Back to Blog

Error Boundaries Done Right: Isolating a Failure Instead of Losing the Page

Learn how to isolate React rendering failures with error boundaries so one bad component never takes down your entire page.

Admin
August 2, 20268 min read0 views

Error Boundaries Done Right: Isolating a Failure Instead of Losing the Page

You've shipped a React app with dozens of components. A user hits a page where one API returns malformed data, a third-party widget throws during render, or a race condition produces undefined where your component expected an object. Without error boundaries, React unmounts the entire component tree — the user sees a blank screen with no explanation. The whole page is gone because of one bad component.

Error boundaries are React's answer to this. They let you isolate failures to the component that failed, show a meaningful fallback UI, and keep the rest of your app running. Getting them right means knowing where to put them, what they catch, and — the part that trips people up — what they don't.

The Problem

Here's what a typical codebase looks like before error boundaries are applied properly:

// Bad: try/catch in render does nothing useful, and one throw kills the whole tree
function Dashboard() {
  return (
    <div className="dashboard">
      <Header />
      <RevenueChart />       {/* If this throws, everything below is gone */}
      <TransactionsTable />  {/* Never renders */}
      <RecommendedCourses /> {/* Never renders */}
    </div>
  );
}
 
function RevenueChart() {
  // The hook is *declared* as returning RevenueData, but on a cache miss the
  // endpoint 204s and this parses to null. The cast is why tsc stayed quiet.
  const data = useRevenueData() as RevenueData;
  return <Line data={data.series} />; // TypeError: Cannot read properties of null
}

When RevenueChart throws during render, React bubbles the error up until it finds an error boundary — or reaches the root and unmounts everything. Your users see a blank page. The TransactionsTable and RecommendedCourses never had a chance to render even though they're completely fine.

This breaks down in three specific ways:

  • All-or-nothing failure: One bad component crashes the whole page
  • No user feedback: Users get a white screen with no explanation or way to recover
  • Silent production failures: Without logging in componentDidCatch, you might not even know it happened

The Pattern

You can write a boundary by hand — it's a class component with getDerivedStateFromError and componentDidCatch, and there's still no hook equivalent, because React needs the two lifecycle methods to do this. In practice most people install react-error-boundary (Brian Vaughn's library, not an official React package, but the de facto standard and the one React's own docs point at) so they don't hand-roll that class in every project:

npm install react-error-boundary

Then wrap your components strategically:

// components/error-boundary.tsx
// The package already ships its own "use client" directive, so this re-export
// is a convenience, not a requirement — it gives you one place to swap the
// implementation and one import path for the rest of the app.
'use client';
 
export { ErrorBoundary } from 'react-error-boundary';
// components/section-fallback.tsx
import { getErrorMessage, type FallbackProps } from 'react-error-boundary';
 
export function SectionFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-4">
      <p className="font-medium text-red-800">This section failed to load</p>
      <p className="mt-1 text-sm text-red-600">
        {getErrorMessage(error) ?? 'Unknown error'}
      </p>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 text-sm font-medium text-red-700 underline"
      >
        Try again
      </button>
    </div>
  );
}

Reaching straight for error.message here is the obvious move and it won't compile under strict. FallbackProps.error is typed unknown, not Error, because JavaScript lets you throw anything — a string, a number, null. The library exports getErrorMessage for exactly this narrowing, which is why it's used above.

// Now isolate failures at the widget level
function Dashboard() {
  return (
    <div className="dashboard">
      <Header /> {/* Critical — let it fail loudly */}
 
      <ErrorBoundary FallbackComponent={SectionFallback}>
        <RevenueChart />  {/* Isolated — only this widget fails */}
      </ErrorBoundary>
 
      <ErrorBoundary FallbackComponent={SectionFallback}>
        <TransactionsTable />  {/* Unaffected by RevenueChart's failure */}
      </ErrorBoundary>
 
      <ErrorBoundary fallback={<p className="text-muted-foreground">Recommendations unavailable.</p>}>
        <RecommendedCourses />  {/* Silent minimal fallback — it's optional */}
      </ErrorBoundary>
    </div>
  );
}

Let's break down the key decisions:

  • FallbackComponent vs fallback: Use FallbackComponent when the fallback needs access to error or resetErrorBoundary. Use the static fallback prop for non-critical widgets where a short message is enough.
  • resetErrorBoundary: Unmounts the fallback and re-renders the children from scratch. Pair it with a "Try again" button for user recovery.
  • Granularity matters: Header is intentionally unprotected — if navigation breaks, that's a page-level failure worth escalating. RecommendedCourses gets a minimal fallback because it's cosmetic.
  • onError for logging: Pass an onError callback to send errors to your monitoring service. This fires in the commit phase, so side effects are safe.
<ErrorBoundary
  FallbackComponent={SectionFallback}
  onError={(error, info) => {
    Sentry.captureException(error, {
      extra: { componentStack: info.componentStack },
    });
  }}
>
  <RevenueChart />
</ErrorBoundary>

How It Flows

Rendering diagram...

That ordering is worth internalising, because it decides where your logging goes: getDerivedStateFromError runs in the render phase and may be replayed, so it must stay pure — flip a flag and nothing else. componentDidCatch runs once in the commit phase, after the fallback has already rendered, which is why it's the safe place for a Sentry call.

Variations

Variation 1: Catching Async Errors with useErrorBoundary

Error boundaries only catch errors during the React render cycle. Event handlers and async/await code run outside that cycle — if they throw, the boundary never sees it. The useErrorBoundary hook bridges this gap:

import { useErrorBoundary } from 'react-error-boundary';
 
function OrderHistory({ userId }: { userId: string }) {
  const { showBoundary } = useErrorBoundary();
  const [orders, setOrders] = useState<Order[]>([]);
 
  useEffect(() => {
    fetchOrders(userId)
      .then(setOrders)
      .catch((error) => {
        // Without showBoundary, this error is invisible to the boundary above
        showBoundary(error); // <-- routes the async error into the nearest boundary
      });
  }, [userId]);
 
  return <OrderList orders={orders} />;
}

Same pattern works for event handlers:

function DeleteOrderButton({ orderId }: { orderId: string }) {
  const { showBoundary } = useErrorBoundary();
 
  const handleDelete = async () => {
    try {
      await deleteOrder(orderId);
    } catch (error) {
      showBoundary(error); // escalates to the nearest ErrorBoundary
    }
  };
 
  return <button onClick={handleDelete}>Delete</button>;
}

Variation 2: Auto-Reset with resetKeys

When your component receives new props after an error — like when the user navigates to a different resource — you want the boundary to reset automatically without requiring a manual click:

function CoursePlayer({ courseSlug }: { courseSlug: string }) {
  return (
    <ErrorBoundary
      FallbackComponent={SectionFallback}
      resetKeys={[courseSlug]}   // <-- resets automatically when slug changes
      onReset={() => {
        // Clean up any state that contributed to the error
        clearVideoPlayerCache();
      }}
    >
      <VideoPlayer slug={courseSlug} />
    </ErrorBoundary>
  );
}

When any value in resetKeys changes (shallow comparison), the boundary clears the error state and re-renders its children — even if the fallback UI is currently showing. The onReset callback fires before the reset, giving you a chance to clean up.

Variation 3: Error Boundaries + Suspense

The modern data-fetching pattern pairs ErrorBoundary with Suspense. Suspense handles the loading state, ErrorBoundary handles failures. The order matters:

function UserProfileSection({ userId }: { userId: string }) {
  return (
    // ErrorBoundary must wrap Suspense — not the other way around
    <ErrorBoundary
      FallbackComponent={SectionFallback}
      resetKeys={[userId]}
    >
      <Suspense fallback={<ProfileSkeleton />}>
        {/* Suspense shows skeleton while loading */}
        {/* ErrorBoundary catches if the promise rejects */}
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

A caveat on how this is usually explained. You'll read that nesting them the wrong way round makes a render error show the skeleton instead of the error fallback. That isn't what happens — I checked on React 19.2, and an ErrorBoundary inside a Suspense catches render errors from its children perfectly well and shows its own fallback.

The real reason to put the boundary outside is narrower, and it's about the skeleton itself. If ProfileSkeleton throws — it takes a prop that's briefly undefined, it reads a context that isn't mounted yet — then a boundary inside Suspense is not in that error's path. The throw happens while React renders the fallback, above the boundary, so it escapes to the root and you get the blank page you were trying to prevent. With the boundary outside, the same throw lands in SectionFallback. Putting it outside also means one boundary covers the loading state and the loaded state, so resetKeys resets the whole section rather than half of it.

Variation 4: Next.js App Router — error.tsx

A client-side ErrorBoundary is the wrong tool for a Server Component's failure — you can't wrap a server render from the client, and the error you'd be catching has already been serialised and shipped over. The framework handles this layer with the error.tsx file convention instead:

// app/dashboard/error.tsx
'use client'; // Must be a client component
 
import { useEffect } from 'react';
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    Sentry.captureException(error);
  }, [error]);
 
  return (
    <div className="flex flex-col items-center gap-4 py-12">
      <h2 className="text-xl font-semibold">Dashboard failed to load</h2>
      <p className="text-muted-foreground">{error.message}</p>
      <button onClick={reset} className="btn-primary">
        Try again
      </button>
    </div>
  );
}

This file becomes an automatic error boundary for the app/dashboard/ route segment. It catches both client render errors and errors from Server Components in that segment. The reset function triggers a re-render of the segment.

For errors in the root layout, create app/global-error.tsx — it must render its own <html> and <body> tags, because the root layout that normally provides them is the thing that just failed.

💡

In Next.js App Router, use error.tsx for route-level boundaries and react-error-boundary for component-level boundaries within client components. They serve different layers of the same protection strategy.

Variation 5: The Root Safety Net

No matter how granular your component-level boundaries are, always add a root-level boundary as a last resort:

// app/layout.tsx or _app.tsx
import { ErrorBoundary } from '@/components/error-boundary';
import { AppErrorFallback } from '@/components/app-error-fallback';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ErrorBoundary
          FallbackComponent={AppErrorFallback}
          onError={(error, info) => Sentry.captureException(error)}
        >
          {children}
        </ErrorBoundary>
      </body>
    </html>
  );
}

This catches anything that slips past your component-level boundaries. The fallback at this level should offer a "Reload page" option since there's no targeted recovery possible.

What Error Boundaries Do NOT Catch

This is the most important thing to understand. The usual one-line summary — "boundaries only catch errors during render" — is close but slightly too narrow, and the gap matters. What a boundary catches is anything React itself was calling when it threw: render, the class lifecycle methods, and the commit phase, which includes your effect bodies. What it can't catch is anything the browser was calling — a click handler, a timer callback, a promise continuation — because by then React isn't on the stack.

Error sourceCaught by boundary?What to do instead
Errors during renderYesNothing — boundary handles it
Errors in lifecycle methodsYesNothing — boundary handles it
A synchronous throw in a useEffect bodyYesNothing — commit-phase errors are caught
Throwing inside startTransition (React 19)YesNothing — React routes it to the boundary
Event handlers (onClick, etc.)Notry/catch in the handler + showBoundary
async/await and rejected PromisesNo.catch(showBoundary) or try/catch + showBoundary
setTimeout / setIntervalNotry/catch inside the callback + showBoundary
Errors while rendering a Suspense fallbackNoBoundary must sit outside the Suspense
componentDidCatch / the fallback itself throwingNoParent boundary up the tree
Server components in Next.jsHandled elsewhereerror.tsx for the segment

The two "Yes" rows people don't expect are worth calling out. A plain throw in an effect body is caught, so you don't need showBoundary for that — you need it only once the failure has become a rejected promise. And React 19 added the startTransition case: throw inside the function you pass to startTransition and the nearest boundary gets it, which is what makes boundaries usable with transition-driven data fetching.

⚠️

Don't judge a boundary by your dev console. When a boundary catches an error, React 19 still reports it — it routes it to the onCaughtError option on createRoot, which by default logs to console.error, and Next.js paints its dev overlay on top. The boundary did work: the fallback renders in development exactly as it does in production. (Older React versions genuinely re-threw caught errors in dev; React 19 replaced that with onCaughtError, so pass your own handler there if you want caught errors quieter or sent somewhere else.)

When NOT to Use This

⚠️

Don't wrap everything in a single top-level boundary and call it done. A single root boundary that swallows all errors creates silent failures — your critical flows (auth, checkout, form submission) should fail loudly, not hide behind a generic "something went wrong" message. Error boundaries are for isolation, not suppression.

Skip granular error boundaries when:

  • You're building a small app or prototype. A single root boundary is fine until you have distinct sections that can meaningfully fail independently.
  • The component is part of a critical flow. Checkout, authentication, and form submission failures should escalate, not be silently contained. Let them bubble up.
  • You'd end up wrapping every single component. If you find yourself wrapping 30 leaf nodes, you're probably masking poor error handling upstream. Fix the data layer instead.
  • The error is recoverable in code. If you can provide a sensible default value or handle null gracefully, do that — don't reach for a boundary when a ?. operator or a default state will do.

The right strategy is three tiers: a root safety net, route-level boundaries (or error.tsx in Next.js), and component-level isolation for non-critical widgets. Everything else should fail on its own terms.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read