Stop Crashing the Whole App: Master React Error Boundary & Suspense
"One Button Crashed the Entire Site"
I recently launched a shopping mall site. On opening day, I was excitedly watching the traffic logs when customer support messages started pouring in. "Every time I click 'Add to Cart', the screen goes white." "Refreshing the page doesn't help. It's just blank."
I investigated and found a trivial typo in the 'Add to Cart' function.
Uncaught Error: Cannot read properties of undefined (reading 'push')
The problem was, because of this one tiny error, the Header, Footer, Product List, and even the Navigation Bar—the entire UI—disappeared, leaving only a White Screen of Death. Users assumed the site was broken and left. It felt incredibly unfair that a single broken button could bring down the entire house.
What Confused Me Initially? (React's Strictness)
I naively thought, "Why can't it just hide the broken part?"
Thinking back to jQuery or pure HTML days, if an image tag broke (<img> showing broken icon), the surrounding text remained visible. A JavaScript error might log a red line in the console, but other buttons usually kept working.
But React has a different philosophy based on its Single Tree Architecture.
If an error occurs anywhere in the tree during the Render Phase using JavaScript, React decides, "It is better to show nothing than to show a corrupted UI."
Leaving a UI in a corrupted state might lead users to interact with wrong data, potentially causing bigger issues (like incorrect payments).
So, React propagates the error from the failing component all the way up to the Root, effectively Unmounting the entire App. This is what causes the white screen.
The 'Aha!' Moment (Circuit Breaker Analogy)
I finally understood this by comparing it to a "Circuit Breaker" in a house.
- Default React Behavior: A short circuit occurs in one bedroom (a component), but instead of just that room losing power, the Main Breaker for the entire house trips. The fridge turns off, the heater stops. Total blackout.
- Error Boundary: This is like installing sub-breakers for each room or zone. "Did the bedroom light explode? Fine, cut power to the bedroom only. The Kitchen and Living Room should stay on."
We need a safety mechanism that, instead of a white screen, shows a Fallback UI: "Something went wrong. Please try again." This is exactly what an Error Boundary does.
The Fix: Implementing react-error-boundary
The official React docs show examples of implementing Error Boundaries using Class Components with componentDidCatch. But I didn't want to use Class Components in 2025.
So, I adopted the standard library react-error-boundary.
Step 1: Isolation Strategy
Wrap dangerous components—especially those fetching APIs or containing complex logic—with the boundary. I isolated major sections to prevent the whole page from dying.
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<Layout>
{/* 1. Even if Sidebar dies, Main Content survives */}
<ErrorBoundary FallbackComponent={SidebarFallback}>
<Sidebar />
</ErrorBoundary>
{/* 2. Even if Content dies, Header survives */}
<ErrorBoundary FallbackComponent={MainFallback}>
<ProductList />
</ErrorBoundary>
</Layout>
);
}
Now, even if ProductList crashes, Sidebar and Layout remain visible. Users can still navigate to other pages. This alone massively improves User Experience (UX).
Step 2: Fallback UI (Be Helpful)
Showing "Error occurred" is only helpful for developers. You need to tell users "What went wrong and What to do."
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert" className="error-container">
<h3>⚠️ Oops! Failed to load products.</h3>
<p>Could you try again in a moment?</p>
{/* Show details only in Dev */}
{process.env.NODE_ENV === 'development' && (
<pre>{error.message}</pre>
)}
{/* The most important part: Retry Button */}
<button onClick={resetErrorBoundary}>
Try Again
</button>
</div>
);
}
The resetErrorBoundary prop is key here. When called, the Error Boundary resets its error state and attempts to Re-mount the child component (ProductList). If the error was a temporary network glitch, this button alone can restore the app to normal.
Step 3: Integration with React Query
If you are using React Query, simply re-rendering isn't enough. You must also clear the cached error data so fetch is triggered again.
react-error-boundary and tanstack-query work perfectly together.
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset} // Resets the query error here!
FallbackComponent={ErrorFallback}
>
<ProductList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Now, clicking "Try Again" does three things: ① Clears query error cache, ② Re-mounts the component, ③ Triggers a fresh API request. A perfect retry mechanism.
Deep Dive: The Trap of Async Errors & Suspense
After applying Error Boundary, I ran some tests and found something strange.
Errors occurring inside useEffect fetch calls were NOT caught by the Error Boundary, resulting in a white screen.
// ❌ Error Boundary fails to catch this
useEffect(() => {
fetch('/api/data').then(res => {
if (!res.ok) throw new Error('API Fail'); // Error here!
});
}, []);
Why?
React Error Boundaries only catch errors during the Render Phase, Lifecycle Methods, and Constructors.
They do NOT catch errors in: Async callbacks (setTimeout, Promise), Event handlers (onClick), or Server-Side Rendering (SSR). These happen outside React's render flow.
Solution 1: useErrorBoundary Hook (Explicit Throw)
You can use the hook provided by the library to throw errors into the render cycle effectively.
import { useErrorBoundary } from 'react-error-boundary';
function MyComponent() {
const { showBoundary } = useErrorBoundary();
useEffect(() => {
fetch('/api/data').catch((error) => {
// Delegate error to Error Boundary!
showBoundary(error);
});
}, []);
// ...
}
Solution 2: Declarative Handling with Suspense (Recommended)
The most elegant way is to use React Query's suspense: true option (or useSuspenseQuery in v5).
With this, Suspense handles the loading state, and data fetching errors are propagated as Render Errors, which ErrorBoundary catches naturally.
Declarative Error Handling Pattern:
{/* 3. Handle Errors */}
<ErrorBoundary fallback={<div>Something went wrong</div>}>
{/* 2. Handle Loading */}
<Suspense fallback={<Skeleton />}>
{/* 1. Render Data (Success Case) */}
<AsyncProductList />
</Suspense>
</ErrorBoundary>
Look at this code. No messy if (isLoading) return ... or if (isError) return ... checks.
You only write the Success UI. Parents (Suspense, ErrorBoundary) take responsibility for loading and errors. This is the essence of Declarative Programming.
Application: Logging with Sentry
Error Boundaries are not just for showing UI; they are the perfect place to log errors. Developers should know about the crash before the user reports it.
Use the onError prop to execute logic when an error is caught.
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => {
console.error("Error Caught:", error);
// Send to Sentry, LogRocket, etc.
Sentry.captureException(error, {
extra: {
componentStack: info.componentStack,
userAction: "Clicked Add to Cart", // Add context
}
});
}}
>
<App />
</ErrorBoundary>
This ensures you get precise logs: "Which component crashed" and "What was the stack trace." Connect this to Slack alerts, and you can fix bugs while others sleep.
One-Line Summary
Letting the whole app crash to a white screen is negligence. Use ErrorBoundary to isolate failures by section, and combine it with Suspense for declarative handling. Giving users a 'Try Again' button instead of a blank screen is the last line of defense for a developer's conscience.