Skip to main content

Error Boundaries & Suspense: Complete Error Handling Strategy

Suspense handles loading states, but Error Boundaries handle failures. When a promise passed to use() rejects, the error is thrown from the component and bubbles to the nearest Error Boundary. Understanding how to layer Error Boundaries with Suspense boundaries creates a complete, resilient data-fetching system that gracefully handles both pending and failed states.

This article covers building Error Boundaries, coordinating them with Suspense, and implementing retry logic for failed requests.

How Errors Flow in Suspense

When a promise rejects, the rejection doesn't go to Suspense—it goes to Error Boundary. This is intentional: Suspense is for loading coordination, Error Boundary is for error recovery.

import { use, Suspense } from 'react';

function DataDisplay({ promise }) {
const data = use(promise);
// If promise is pending: use() throws the promise (caught by Suspense)
// If promise is fulfilled: use() returns data (no throw)
// If promise is rejected: use() throws the error (caught by Error Boundary)
return <div>{data.value}</div>;
}

class ErrorBoundary extends React.Component {
state = { error: null };

static getDerivedStateFromError(error) {
return { error };
}

render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}

export function App() {
const promise = fetch('/api/data').then(r => r.json());

return (
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<DataDisplay promise={promise} />
</Suspense>
</ErrorBoundary>
);
}

If the fetch fails (network error, 404, 500), the promise rejects. use() throws the error. The Error Boundary catches it and displays the error message. The Suspense fallback is never shown because the error is thrown before Suspense can handle loading.

Building a Reusable Error Boundary

A basic Error Boundary is useful, but production apps need more features: recovery, error logging, and fallback UI:

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
error: null,
errorInfo: null,
errorCount: 0,
};
}

static getDerivedStateFromError(error) {
return { error };
}

componentDidCatch(error, errorInfo) {
// Log the error to an error tracking service
console.error('Error caught:', error, errorInfo);
// Example: Sentry.captureException(error);

// Track error count for retry logic
this.setState(prev => ({
errorInfo,
errorCount: prev.errorCount + 1,
}));
}

handleReset = () => {
this.setState({ error: null, errorInfo: null });
};

render() {
if (this.state.error) {
return (
<div style={{ padding: '2rem', background: '#ffe0e0' }}>
<h2>Something went wrong</h2>
<p>{this.state.error.message}</p>
{process.env.NODE_ENV === 'development' && (
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.errorInfo?.componentStack}
</details>
)}
<button onClick={this.handleReset}>Try again</button>
</div>
);
}

return this.props.children;
}
}

This Error Boundary:

  • Logs errors to console (or an error tracking service).
  • Tracks error count for smart retry logic.
  • Displays a recovery button so users can retry.
  • Shows the component stack in development (helps debugging).

Layering Error Boundary and Suspense

The correct pattern is: Error Boundary wraps Suspense. This ensures the boundary catches both errors and loading states:

<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<DataDisplay promise={promise} />
</Suspense>
</ErrorBoundary>

The order matters:

  • Error Boundary outside: Catches errors from the entire subtree (including Suspense child errors).
  • Suspense inside: Catches promises and shows fallback while loading.

Never wrap Error Boundary inside Suspense—the boundary can't catch errors from its own ancestors.

Handling Different Error Types

Not all errors are data-fetching errors. Some are component rendering errors. Distinguish between them:

import { use, Suspense } from 'react';

function DataDisplay({ promise }) {
const data = use(promise);

// This error happens during rendering, not fetch
if (!data || typeof data.value !== 'number') {
throw new Error('Data is malformed');
}

return <div>{data.value}</div>;
}

class SmartErrorBoundary extends React.Component {
state = { error: null };

static getDerivedStateFromError(error) {
return { error };
}

componentDidCatch(error) {
if (error.message.includes('fetch')) {
// Network error—suggest retry
console.warn('Network error, user can retry');
} else if (error.message.includes('malformed')) {
// Data error—log and alert ops
console.error('Data validation error');
} else {
// Unknown error—log to error tracking
console.error('Unexpected error');
}
}

render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}

By checking the error message, you can handle different scenarios: retry network errors, alert data issues, or notify ops for unknown errors.

Implementing Retry Logic

Retry logic is essential for transient failures (network hiccups, temporary downtime). Use startTransition to retry without blocking the UI:

import { use, Suspense, startTransition, useState } from 'react';

function DataDisplay({ promise }) {
const data = use(promise);
return <div>{data.value}</div>;
}

class RetryErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null, retryCount: 0 };
}

static getDerivedStateFromError(error) {
return { error };
}

componentDidCatch(error) {
// Auto-retry after a delay (exponential backoff)
if (this.state.retryCount < 3) {
setTimeout(() => {
this.handleRetry();
}, 1000 * Math.pow(2, this.state.retryCount)); // 1s, 2s, 4s
}
}

handleRetry = () => {
this.setState(prev => ({
error: null,
retryCount: prev.retryCount + 1,
}));
// Trigger re-render; parent component creates a new promise
this.props.onRetry?.();
};

render() {
if (this.state.error) {
return (
<div>
<p>Error: {this.state.error.message}</p>
<p>Retrying {this.state.retryCount}/3...</p>
<button onClick={this.handleRetry}>Retry now</button>
</div>
);
}
return this.props.children;
}
}

export function Page() {
const [retryKey, setRetryKey] = useState(0);

// Create a new promise on each retry
const promise = fetch(`/api/data?retry=${retryKey}`)
.then(r => r.json());

return (
<RetryErrorBoundary onRetry={() => setRetryKey(prev => prev + 1)}>
<Suspense fallback={<div>Loading...</div>} key={retryKey}>
<DataDisplay promise={promise} />
</Suspense>
</RetryErrorBoundary>
);
}

This pattern:

  1. Shows a loading state while fetching.
  2. If the request fails, the Error Boundary catches the error.
  3. The boundary automatically retries (up to 3 times) with exponential backoff.
  4. If all retries fail, the user sees an error message and a manual "Retry now" button.

Granular Error Boundaries

Use multiple Error Boundaries to isolate failures. If one section fails, others continue working:

export function Dashboard() {
const userPromise = fetch('/api/user').then(r => r.json());
const statsPromise = fetch('/api/stats').then(r => r.json());
const postsPromise = fetch('/api/posts').then(r => r.json());

return (
<main>
{/* Header has its own boundary */}
<ErrorBoundary>
<Suspense fallback={<div>Loading header...</div>}>
<Header userPromise={userPromise} />
</Suspense>
</ErrorBoundary>

<div style={{ display: 'flex' }}>
{/* Sidebar has its own boundary */}
<ErrorBoundary>
<Suspense fallback={<div>Loading stats...</div>}>
<Sidebar statsPromise={statsPromise} />
</Suspense>
</ErrorBoundary>

{/* Main content has its own boundary */}
<ErrorBoundary>
<Suspense fallback={<div>Loading posts...</div>}>
<MainContent postsPromise={postsPromise} />
</Suspense>
</ErrorBoundary>
</div>
</main>
);
}

If the stats API fails, the sidebar shows an error, but the header and content still load. Users can still interact with working sections.

Testing Error Boundaries

Test Error Boundaries by rendering them with a promise that rejects:

import { render, screen } from '@testing-library/react';
import { use, Suspense } from 'react';

function Component({ promise }) {
const data = use(promise);
return <div>{data.value}</div>;
}

class ErrorBoundary extends React.Component {
state = { error: null };

static getDerivedStateFromError(error) {
return { error };
}

render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}

test('catches errors from rejected promises', async () => {
const rejectedPromise = Promise.reject(new Error('API failed'));

render(
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Component promise={rejectedPromise} />
</Suspense>
</ErrorBoundary>
);

// Wait for the error to be caught and rendered
const errorMsg = await screen.findByText(/Error: API failed/);
expect(errorMsg).toBeInTheDocument();
});

Always wrap with Suspense in tests that use use().

Common Pitfall: Error Boundary Class Component Requirement

Error Boundaries must be class components. Hooks don't support error handling. If you need a functional component, use a wrapper:

// This won't work: functional component can't be an error boundary
function ErrorBoundary({ children }) {
// ❌ No getDerivedStateFromError hook
return children;
}

// Correct: class component
class ErrorBoundary extends React.Component {
static getDerivedStateFromError(error) {
return { error };
}

render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}

For functional components, use a library like react-error-boundary:

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

function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}

export function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<div>Loading...</div>}>
<DataDisplay promise={promise} />
</Suspense>
</ErrorBoundary>
);
}

Key Takeaways

  • Error Boundary wraps Suspense: Boundaries catch errors; Suspense handles loading.
  • Errors from use() skip Suspense: They bubble directly to Error Boundary.
  • Granular boundaries isolate failures: One section's error doesn't break the entire page.
  • Implement retry logic: Exponential backoff for transient failures; manual button for user control.
  • Error Boundaries must be class components: Or use a library like react-error-boundary.

Frequently Asked Questions

Can a Suspense boundary catch errors?

No. Suspense only catches promises. Errors are thrown and bubble to Error Boundary. They are separate concerns: loading (Suspense) vs. failures (Error Boundary).

What if an Error Boundary itself has an error?

It propagates to the next Error Boundary up the tree. If no boundaries are found, React will unmount the app (shows a blank page). Always have a top-level Error Boundary.

How do I test Error Boundaries?

Use jest.spyOn(console, 'error') to suppress expected error logs, then render a component that throws:

beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
console.error.mockRestore();
});

test('catches component errors', () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText(/Error:/)).toBeInTheDocument();
});

Can I catch errors from event handlers?

No. Error Boundaries only catch errors during render, not in event handlers or async callbacks. For event handler errors, use try-catch or .catch() on promises.

Should I log errors to an external service?

Yes. In componentDidCatch, send errors to a service like Sentry:

componentDidCatch(error, errorInfo) {
Sentry.captureException(error, { contexts: { react: errorInfo } });
}

This helps you monitor production errors and fix them quickly.

Further Reading