Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 060 / 100

Error boundaries in React for React & Next.js developers

A crash in one widget should not blank the page.

Expona Admin6 min read

Last week, a single widget crash took down our entire booking flow for 20 minutes. A null reference in a nested component brought the whole page to a blank screen, leaving customers staring at nothing. That's when I realized we needed error boundaries in React—a mechanism that catches rendering errors before they obliterate your UI and lets you show a graceful fallback instead.

What error boundaries actually catch

The first thing I had to understand was the scope. Error boundaries are not a catch-all. They intercept errors during the render phase—when React is building the component tree. If a component throws during render(), a lifecycle method, or the constructor, the boundary catches it.

But here's what they don't catch:

  • Event handlers (use try-catch or error states instead)
  • Async code in useEffect (wrap the async call in a try-catch)
  • Server-side rendering errors
  • Errors in the boundary component itself
  • Timeouts or promise rejections

In practice, I've found render errors are often the worst culprits: a missing API response, a malformed object, or a broken list iteration. Here's what a typical render error looks like:

tsx

function ProductCard({ product }) {
  // Crash: product might be null, undefined, or missing 'name'
  return <h3>{product.name}</h3>;
}

export default function App() {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    // If this fetch fails, product stays null
    fetch('/api/product').then(r => r.json()).then(setProduct);
  }, []);

  return <ProductCard product={product} />;
}

Without a boundary, a crash in ProductCard nukes the entire app. With one, you show an error UI and the rest of the page survives.

Granular boundaries—wrap independent widgets

My biggest mistake early on was wrapping my entire app in a single boundary. That defeats the purpose. If one widget fails, you still get a blank page.

The smarter approach is granular boundaries. Wrap independent, self-contained features—a product card, a user profile, a comment section, a sidebar widget. When one crashes, its neighbors keep working.

Here's how I'd structure a dashboard with multiple widgets:

tsx

import ErrorBoundary from './ErrorBoundary';
import RevenueChart from './widgets/RevenueChart';
import UserTable from './widgets/UserTable';
import ActivityFeed from './widgets/ActivityFeed';

export default function Dashboard() {
  return (
    <div className="dashboard">
      <ErrorBoundary fallback={<ChartError />}>
        <RevenueChart />
      </ErrorBoundary>

      <ErrorBoundary fallback={<TableError />}>
        <UserTable />
      </ErrorBoundary>

      <ErrorBoundary fallback={<FeedError />}>
        <ActivityFeed />
      </ErrorBoundary>
    </div>
  );
}

Now if RevenueChart crashes, the user still sees the table and feed. Each widget is isolated. This is the practice that saved us multiple times in production—a broken chart doesn't disable the user's ability to manage their data.

Helpful fallbacks—explain and offer a retry

A blank error screen is nearly as bad as a crash. I've seen "Something went wrong" appear on production and watch support tickets pile up because users don't know what happened or what to do next.

A good fallback should:

  1. Explain what happened in plain language
  2. Suggest next steps (reload, contact support, try again)
  3. Offer a way to reset and retry
  4. Log the error so you can debug it

Here's a practical fallback component I've used:

tsx

export default function ErrorFallback({ error, resetErrorBoundary }) {
  useEffect(() => {
    // Log to error tracking service
    console.error('Render error:', error);
    // Sentry, LogRocket, etc.
  }, [error]);

  return (
    <div style={{ padding: '20px', border: '1px solid #f5222d', borderRadius: '4px' }}>
      <h4>Something went wrong</h4>
      <p>We encountered an error while loading this section. Try refreshing or contact support if the problem persists.</p>
      <details style={{ marginTop: '12px', fontSize: '12px', color: '#666' }}>
        <summary>Error details</summary>
        <pre>{error.message}</pre>
      </details>
      <button onClick={resetErrorBoundary} style={{ marginTop: '12px' }}>
        Try again
      </button>
    </div>
  );
}

The "Try again" button is crucial. It gives users agency and resets the boundary's error state, allowing them to retry without a full page reload.

react-error-boundary—a small library with reset keys and hooks

Writing error boundaries using class components feels archaic. I switched to react-error-boundary, a lightweight library that lets you write boundaries with hooks.

Install it:

tsx

npm install react-error-boundary

Use the component:

tsx

import { ErrorBoundary } from 'react-error-boundary';
import ErrorFallback from './ErrorFallback';

export default function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <YourApp />
    </ErrorBoundary>
  );
}

The library's two killer features are reset keys and hooks.

Reset keys let you automatically reset the boundary when a prop changes. If your widget depends on a user ID, you can reset the error state when the user ID changes:

tsx

<ErrorBoundary
  FallbackComponent={ErrorFallback}
  resetKeys={[userId]}
>
  <UserProfile userId={userId} />
</ErrorBoundary>

When userId changes, the boundary resets automatically. No manual intervention needed.

You can also use the useErrorHandler hook inside a component to programmatically throw errors that the boundary will catch:

tsx

import { useErrorHandler } from 'react-error-boundary';

function DataFetcher() {
  const [data, setData] = useState(null);
  const handleError = useErrorHandler();

  useEffect(() => {
    fetch('/api/data')
      .then(r => r.json())
      .then(setData)
      .catch(err => {
        // Boundary catches this
        handleError(err);
      });
  }, [handleError]);

  return data ? <div>{data.title}</div> : <p>Loading...</p>;
}

This is particularly useful for async errors that wouldn't normally bubble up to the boundary.

Common mistakes

Mistake 1: Wrapping your entire app in a single boundary. I did this once. A crash anywhere still blanked the page. Split boundaries by feature or widget.

Mistake 2: Using boundaries for async errors. Boundaries catch render errors, not promises. Wrap async calls in try-catch or use error states.

Mistake 3: Not logging errors. A fallback UI hides the problem from the user but you still need to know it happened. Log to Sentry, LogRocket, or your own error service every time.

Mistake 4: Unclear fallback messages. "Error" or "Something went wrong" frustrates users. Explain what section failed and suggest a next step.

Mistake 5: Not resetting the boundary. If your error fallback has a "Try again" button, wire it to resetErrorBoundary so the user can retry without a full page reload.

Key takeaways

  • Error boundaries catch render errors in their child tree but not event handlers or async code. Use try-catch for those.
  • Granular boundaries are better than app-wide ones. Wrap independent widgets so a crash in one doesn't nuke everything.
  • Good fallbacks explain what happened, log the error, and offer a way to retry. Don't just say "Error".
  • Use react-error-boundary instead of class components. It gives you hooks, reset keys, and cleaner syntax.
  • Reset keys automate boundary resets when key props change, reducing manual state management.
  • Always log errors to an external service. Fallback UIs hide problems from users but you need visibility for debugging.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles