Error Boundaries: When Components Crash

SS Saurav Sitaula

Learn to implement React Error Boundaries to catch JavaScript errors and display fallback UIs. Understand getDerivedStateFromError, componentDidCatch, strategic boundary placement, and the react-error-boundary library. Build resilient React applications that gracefully handle failures.

The Day Everything Went White

After exploring modern state management and building apps with React Query and Zustand, I shipped a feature I was proud of: a user profile page with charts, stats, and real-time data.

Two hours later, the support tickets started rolling in.

“The page is blank.” “I can’t see anything.” “Everything disappeared.”

I checked the production logs. There it was: TypeError: Cannot read property 'name' of undefined.

One user’s profile had malformed data. When that component tried to render it, it threw an error. And because React didn’t know how to handle it, the entire application crashed.

Not just that component. Not just that page. The whole app.

The Unforgiving Nature of JavaScript Errors

Here’s the thing about React: when a component throws an error during rendering, it doesn’t gracefully degrade. By default, it unmounts the entire component tree.

function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>  {/* 💥 If user is undefined... BOOM */}
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  const [user, setUser] = useState(null);
  
  return (
    <div>
      <Header />
      <Sidebar />
      <UserProfile user={user} />  {/* One crash here... */}
      <Footer />  {/* ...kills everything */}
    </div>
  );
}

In regular JavaScript, you’d use try-catch. But that doesn’t work for render errors:

function App() {
  try {
    return <UserProfile user={null} />;
  } catch (error) {
    // 😰 This NEVER catches render errors!
    return <ErrorMessage />;
  }
}

React rendering is declarative. You’re not calling functions imperatively. Try-catch doesn’t work here.

Enter: Error Boundaries

React 16 introduced error boundaries—special components that catch JavaScript errors in their child component tree.

Think of them as try-catch, but for React components.

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

  static getDerivedStateFromError(error) {
    // Update state so next render shows fallback UI
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // Log error to an error reporting service
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Yes, it’s a class component. Error boundaries require class components because they need getDerivedStateFromError and componentDidCatch. There’s no hook equivalent (yet).

Using Error Boundaries

Wrap the parts of your app that might fail:

function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <ErrorBoundary>
        <UserProfile user={user} />  {/* If this crashes... */}
      </ErrorBoundary>  {/* ...only this section shows fallback */}
      <Footer />
    </div>
  );
}

Now when UserProfile throws, only that section shows the error fallback. Header, Sidebar, and Footer keep working.

Strategic Error Boundary Placement

Where you place error boundaries matters. I learned this the hard way.

Too few boundaries:

<ErrorBoundary>
  <App />  {/* One error anywhere crashes everything */}
</ErrorBoundary>

This is barely better than no error boundary at all.

Too many boundaries:

<ErrorBoundary>
  <Header>
    <ErrorBoundary><Logo /></ErrorBoundary>
    <ErrorBoundary><NavLinks /></ErrorBoundary>
    <ErrorBoundary><UserMenu /></ErrorBoundary>
  </Header>
</ErrorBoundary>

This is overkill. You end up with error messages everywhere.

Just right:

function App() {
  return (
    <div>
      <ErrorBoundary fallback={<HeaderError />}>
        <Header />
      </ErrorBoundary>
      
      <main>
        <ErrorBoundary fallback={<SidebarError />}>
          <Sidebar />
        </ErrorBoundary>
        
        <ErrorBoundary fallback={<ContentError />}>
          <MainContent />
        </ErrorBoundary>
      </main>
      
      <Footer />  {/* Footer is simple, unlikely to crash */}
    </div>
  );
}

Each major section has its own boundary. An error in the sidebar doesn’t take down the main content.

The Pattern I Use Everywhere

After a lot of iteration, here’s my reusable error boundary:

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

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // Send to error tracking service
    if (this.props.onError) {
      this.props.onError(error, errorInfo);
    }
  }

  render() {
    if (this.state.hasError) {
      // Allow custom fallback UI
      if (this.props.fallback) {
        return this.props.fallback;
      }
      
      // Or render prop for access to error
      if (this.props.fallbackRender) {
        return this.props.fallbackRender({
          error: this.state.error,
          resetError: () => this.setState({ hasError: false }),
        });
      }
      
      // Default fallback
      return <div>Something went wrong.</div>;
    }

    return this.props.children;
  }
}

Usage options:

// Simple fallback
<ErrorBoundary fallback={<p>Error loading user profile</p>}>
  <UserProfile />
</ErrorBoundary>

// Fallback with error details and retry
<ErrorBoundary 
  fallbackRender={({ error, resetError }) => (
    <div>
      <p>Failed to load: {error.message}</p>
      <button onClick={resetError}>Retry</button>
    </div>
  )}
>
  <DataChart />
</ErrorBoundary>

// With error logging
<ErrorBoundary 
  onError={(error, info) => sendToErrorService(error, info)}
  fallback={<ErrorUI />}
>
  <CriticalFeature />
</ErrorBoundary>

What Error Boundaries Don’t Catch

This tripped me up. Error boundaries catch errors in:

  • Rendering
  • Lifecycle methods
  • Constructors of child components

They do not catch:

  • Event handlers
  • Async code (setTimeout, promises)
  • Server-side rendering
  • Errors in the error boundary itself

For event handlers, you still need try-catch:

function Button() {
  const handleClick = () => {
    try {
      doSomethingRisky();
    } catch (error) {
      // Handle it yourself
      setError(error.message);
    }
  };
  
  return <button onClick={handleClick}>Click me</button>;
}

For async errors:

function DataLoader() {
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetchData()
      .then(setData)
      .catch(err => setError(err));  // Handle async errors manually
  }, []);
  
  if (error) return <ErrorMessage error={error} />;
  return <DataDisplay data={data} />;
}

Error Boundaries + React Query/SWR

If you’re using a data fetching library, they often have built-in error handling:

function UserProfile({ userId }) {
  const { data, error, isLoading } = useQuery(['user', userId], fetchUser);
  
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <Profile user={data} />;
}

But I still wrap these in error boundaries. Why? Because what if the success case throws?

function UserProfile({ userId }) {
  const { data } = useQuery(['user', userId], fetchUser);
  
  // What if data.profile.avatar.url is undefined?
  // React Query won't catch this render error!
  return <img src={data.profile.avatar.url} />;
}

Belt and suspenders: catch async errors in hooks, catch render errors in boundaries.

Testing Error Boundaries

This was annoying to figure out. React logs errors to the console even when they’re caught:

// In your test
test('shows fallback on error', () => {
  // Suppress console.error for this test
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  
  const ThrowError = () => {
    throw new Error('Test error');
  };
  
  render(
    <ErrorBoundary fallback={<div>Error occurred</div>}>
      <ThrowError />
    </ErrorBoundary>
  );
  
  expect(screen.getByText('Error occurred')).toBeInTheDocument();
  
  spy.mockRestore();
});

react-error-boundary: Don’t Reinvent the Wheel

After building my own error boundary multiple times, I discovered react-error-boundary. It does everything I needed and more:

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

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => logErrorToService(error, info)}
      onReset={() => {
        // Reset app state here
      }}
    >
      <MainApp />
    </ErrorBoundary>
  );
}

It even has a useErrorBoundary hook for triggering errors from event handlers:

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

function Button() {
  const { showBoundary } = useErrorBoundary();
  
  const handleClick = async () => {
    try {
      await riskyOperation();
    } catch (error) {
      showBoundary(error);  // Trigger the nearest error boundary!
    }
  };
  
  return <button onClick={handleClick}>Do risky thing</button>;
}

What I Wish I’d Known Earlier

  1. Error boundaries are not optional. Any production React app needs them. Full stop.

  2. The white screen of death is preventable. One error boundary at the root prevents the worst case. Multiple granular boundaries provide better UX.

  3. Error boundaries are class components (for now). Don’t fight it. Use a library or write one class.

  4. Log your errors. componentDidCatch is perfect for sending errors to Sentry, LogRocket, or your own logging service.

  5. Provide a way to recover. A “retry” button that resets the error state gives users a way forward instead of a dead end.

  6. Test the unhappy path. Write tests that verify your error boundaries show the right fallback.

The Journey Continues

Error boundaries taught me to think about failure modes. What happens when things go wrong? How can the app gracefully degrade?

But there’s another common “failure” state I was handling poorly: loading. Every component had its own loading spinner logic. isLoading ? <Spinner /> : <Content /> repeated everywhere.

Then I learned about Suspense—React’s answer to coordinating loading states.


P.S. — That blank screen incident taught me something important: users don’t see your error messages in the console. They just see nothing. An ugly error fallback is infinitely better than a white void. Ship your error boundaries before you ship your features.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism