Your React App Doesn't Have to White-Screen: A Field Guide to Graceful Failure
A few years ago I got paged at 2 a.m. because our checkout flow had gone completely white. Not "showing an error message" white — actually blank, just the body background color and nothing else, for every user, on every browser. The cause turned out to be a single undefined.toFixed() call three components deep in a price breakdown widget that nobody had touched in months. One malformed API response, one uncaught exception, and React just... stopped rendering the entire tree. That night I finally understood why error boundaries exist, and why almost nobody uses them properly.
Most teams add exactly one error boundary, at the very top of the app, right below . It catches the crash, shows a "Something went wrong, please refresh" message, and calls it a day. That's better than nothing, but it's the equivalent of putting a single circuit breaker on your entire house instead of one per room. When the microwave shorts out, you lose the lights too.
The mental model: blast radius, not just catching errors
The right way to think about error boundaries isn't "where do I catch exceptions" — it's "what is the smallest unit of my UI that can be allowed to fail without taking anything else down with it." A comment thread failing shouldn't take down the article. A stock ticker widget throwing shouldn't kill your entire dashboard. A single row in a table failing to render shouldn't blank the whole table.
Here's a basic boundary, but written with the pieces people usually skip — a reset mechanism and actual logging:
import React from "react";
type Props = {
children: React.ReactNode;
fallback: (error: Error, reset: () => void) => React.ReactNode;
onError?: (error: Error, info: React.ErrorInfo) => void;
};
type State = { error: Error | null };
export class ErrorBoundary extends React.Component {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}
Notice there's no hook version of this — as of React 19 there still isn't, because componentDidCatch and getDerivedStateFromError are lifecycle methods with no functional equivalent. If you want a hook-friendly experience, wrap it, or use react-error-boundary from the npm registry, which is what I actually reach for in production because it also gives you a resetKeys prop that re-renders the children when specific values change — genuinely useful for things like "retry when the route changes."
Layering boundaries by blast radius
In the checkout incident, the fix wasn't just "add a top-level boundary." It was restructuring the page so that each independently-fetched section had its own boundary:
function CheckoutPage() {
return (
} onError={logToSentry}>
} onError={logToSentry}>
} onError={logToSentry}>
);
}
If PriceBreakdown throws now, the user still sees their cart, can still apply a promo code, and can still click "place order" once pricing recovers. That's the actual win — not eliminating errors, which is impossible, but shrinking their blast radius down to "one widget looks broken" instead of "the app is gone."
Retry logic that doesn't retry-loop into oblivion
A fallback that just says "Something broke" is a dead end. A fallback with a working retry button is what turns an outage into a hiccup. But naive retry logic is dangerous — if the underlying cause is a bad response that will always throw, a user mashing "retry" can hammer your API or, worse, get stuck in an infinite re-render loop if the reset happens synchronously inside the render path.
I like combining resetKeys with a capped, backed-off retry count stored outside the boundary itself:
function PricingFallback({ onRetry }: { onRetry: () => void }) {
const [attempts, setAttempts] = React.useState(0);
const maxed = attempts >= 3;
return (
We couldn't load pricing.
);
}
This is deliberately dumb — three tries, then it stops offering the button and points the user somewhere else. I've found that adding exponential backoff timers here is usually overkill for UI-triggered retries; it matters a lot more for background retries (polling, websocket reconnects) where you're not waiting on a human to click something.
Async errors won't be caught — and that trips people up constantly
The thing that catches almost everyone off guard the first time: error boundaries only catch errors thrown during rendering, in lifecycle methods, and in constructors. They do not catch errors inside event handlers, setTimeout callbacks, or async functions like a rejected fetch. This is the single most common reason someone says "I added an error boundary but it's still not catching anything."
For async work, you need to catch the error yourself and push it into state so React sees it during render:
function useThrowAsyncError() {
const [, setState] = React.useState();
return React.useCallback((error: Error) => {
setState(() => {
throw error;
});
}, []);
}
function PriceBreakdown() {
const throwAsyncError = useThrowAsyncError();
React.useEffect(() => {
fetchPricing().catch(throwAsyncError);
}, [throwAsyncError]);
// ...
}
That setState(() => { throw error }) trick looks hacky because it is — you're forcing a synchronous throw during the render phase so the nearest boundary picks it up. It works reliably, and it's what several popular error-handling libraries do under the hood, but if you're building something from scratch, know that you're intentionally exploiting React's render cycle, not using a documented "async error" API. In React 19, onCaughtError and onUncaughtError root options give you a cleaner top-level hook for logging without needing tricks like this at the boundary level, worth adopting if you're on the latest version.
Logging is the part people skip, and it's the part that matters most
An error boundary without logging is a UX improvement that actively hides bugs from you. I've seen teams add boundaries, watch their support tickets and Sentry noise both drop, and then quietly ship regressions for weeks because nobody was actually looking at what the boundaries were catching. Wire componentDidCatch to your error tracker with real context — which boundary, what props were in scope, what the user was doing:
onError={(error, info) => {
Sentry.captureException(error, {
contexts: { react: { componentStack: info.componentStack } },
tags: { boundary: "checkout-pricing" },
});
}}
Give each boundary a distinct tag. When you're triaging later, "checkout-pricing threw 40 times this week" is actionable in a way that "something threw somewhere" is not.
Degrading gracefully beyond just error boundaries
Boundaries handle exceptions, but graceful degradation is bigger than exception handling. Stale-while-revalidate caching (React Query and SWR both do this well) means that if a background refetch fails, the user keeps seeing the last good data instead of a spinner or an error. Feature flags let you kill a misbehaving feature in production without a deploy — LaunchDarkly and open-source alternatives like Unleash are worth the integration cost for exactly this reason. And plain old defensive rendering — checking data?.items ?? [] instead of assuming a shape — prevents a huge share of the crashes that would otherwise reach a boundary at all.
The checkout incident ended up changing how our team reviews PRs: any new data-fetching component needs its own boundary and a real, non-generic fallback before it merges. It's a small tax on every PR, and it's paid for itself many times over — the next time an API returned garbage, exactly one widget looked broken, and everyone else finished checking out without ever knowing anything had gone wrong.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.