Error Boundaries: When Components Crash
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
-
Error boundaries are not optional. Any production React app needs them. Full stop.
-
The white screen of death is preventable. One error boundary at the root prevents the worst case. Multiple granular boundaries provide better UX.
-
Error boundaries are class components (for now). Don’t fight it. Use a library or write one class.
-
Log your errors.
componentDidCatchis perfect for sending errors to Sentry, LogRocket, or your own logging service. -
Provide a way to recover. A “retry” button that resets the error state gives users a way forward instead of a dead end.
-
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.
Saurav Sitaula
Software Architect • Nepal