The Tuesday Our Dashboard Went White (Error Boundaries the Hard Way)
The Tuesday Our Dashboard Went White#
TL;DR
- One malformed API response crashed our entire React tree because we had exactly ONE error boundary at the app root
- Error boundaries are blast radius limiters — granularity matters more than having one at all
react-error-boundaryhandles recovery patterns that a hand-rolled class component won't- Every async section needs three explicit states: loading (Suspense), error (Error Boundary + retry), success
It was a Tuesday afternoon, and I had just poured my third mate of the day when Slack exploded. The monitoring channel lit up with screenshots. Our internal dashboard — the one used by about 2,000 people across the company — was completely white. Not a loading spinner. Not an error message. Just... white.
I opened the dashboard in my browser. White screen. Opened dev tools. A red wall of Uncaught Error: Cannot read properties of undefined (reading 'currency') cascading through the console.
One of the API endpoints had started returning a response where a nested currency field was missing — some backend change that didn't account for legacy records. A single undefined property access inside a render function. And because we had exactly one error boundary — at the app root — that single error took down the entire page.
The sidebar, the navigation, the other 15 widgets that had nothing to do with currency data — all gone. White screen.
It took us 40 minutes to deploy the backend fix. 40 minutes of a white screen for everyone. That afternoon, I blocked my calendar and started learning about error boundaries properly.
Why Class Components (Still, in 2022)#
The first thing that annoyed me: error boundaries must be class components. There's an RFC for a useErrorBoundary hook, but as of September 2022 it doesn't exist. You need componentDidCatch and getDerivedStateFromError, and hooks can't do lifecycle methods.
Here's the minimal version:
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class BasicErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// log it, because you WILL forget otherwise
console.error("Caught by ErrorBoundary:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
This works. But it's missing recovery. Once the error state is set, the only way out is a full page reload. After the incident, "reload the page" was not an acceptable recovery story.
Use react-error-boundary (Seriously, Don't Roll Your Own)#
After our incident I found react-error-boundary by Brian Vaughn. It handles the class component boilerplate and adds every recovery pattern we needed.
npm install react-error-boundary
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
function DataFetchError({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="error-panel">
<h2>Something went wrong</h2>
<pre className="error-message">{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function TransactionSection({ accountId }: { accountId: string }) {
return (
<ErrorBoundary FallbackComponent={DataFetchError}>
<TransactionList accountId={accountId} />
</ErrorBoundary>
);
}
resetErrorBoundary clears the error and re-renders the children. If the problem was transient — a network blip, a race condition, a backend deploy that fixed itself — clicking "Try again" recovers without a reload. That was the first thing we needed.
The Fix: Suspense + Error Boundary, Together#
After the incident, we adopted a pattern where every async section gets both a Suspense boundary and an Error Boundary. One handles loading. The other handles failure. They're complementary.
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
interface AsyncSectionProps {
fallback: React.ReactNode;
errorFallback: React.ComponentType<FallbackProps>;
onReset?: () => void;
children: React.ReactNode;
}
// we use this wrapper everywhere now
function AsyncSection({ fallback, errorFallback, onReset, children }: AsyncSectionProps) {
return (
<ErrorBoundary FallbackComponent={errorFallback} onReset={onReset}>
<Suspense fallback={fallback}>
{children}
</Suspense>
</ErrorBoundary>
);
}
function AccountDashboard({ accountId }: { accountId: string }) {
return (
<div>
<AsyncSection
fallback={<MetricsSkeleton />}
errorFallback={MetricsError}
>
<AccountMetrics accountId={accountId} />
</AsyncSection>
<AsyncSection
fallback={<TransactionsSkeleton />}
errorFallback={TransactionsError}
>
<RecentTransactions accountId={accountId} />
</AsyncSection>
</div>
);
}
Now if AccountMetrics crashes, RecentTransactions keeps working. The blast radius is one section, not the entire page. If we'd had this on that Tuesday, only the currency widget would've shown an error. Everything else would've been fine.
Recovery Patterns We Built#
Pattern 1: Simple Retry#
function SectionErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="section-error">
<p>Failed to load this section.</p>
<p className="error-detail">{error.message}</p>
<button onClick={resetErrorBoundary} className="retry-button">
Retry
</button>
</div>
);
}
Pattern 2: Auto-Reset on Navigation#
Sometimes errors clear naturally when the user navigates. resetKeys watches a list of values and auto-resets when they change:
function AccountPage() {
const { accountId } = useParams();
return (
<ErrorBoundary
FallbackComponent={AccountError}
resetKeys={[accountId]} // navigating to another account clears the error
>
<Suspense fallback={<AccountSkeleton />}>
<AccountDetail accountId={accountId} />
</Suspense>
</ErrorBoundary>
);
}
If the user goes from /accounts/123 to /accounts/456 while seeing an error, the boundary resets automatically. Before we had this, users would see a stale error even after navigating away. Not great.
Pattern 3: Log to Sentry and Escalate#
import * as Sentry from "@sentry/react";
function AppErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
FallbackComponent={AppCrashFallback}
onError={(error, info) => {
Sentry.captureException(error, {
extra: {
componentStack: info.componentStack,
},
});
}}
>
{children}
</ErrorBoundary>
);
}
function AppCrashFallback({ resetErrorBoundary }: FallbackProps) {
return (
<main role="main" className="crash-page">
<h1>Something went wrong</h1>
<p>The application encountered an unexpected error.</p>
<div className="crash-actions">
<button onClick={resetErrorBoundary}>Try to recover</button>
<button onClick={() => window.location.reload()}>Reload page</button>
</div>
</main>
);
}
Pattern 4: Throwing from Event Handlers#
Error boundaries only catch render errors. They don't catch errors from event handlers or async code. The useErrorBoundary hook from react-error-boundary bridges that gap:
import { useErrorBoundary } from "react-error-boundary";
function DataTable({ reportId }: { reportId: string }) {
const [data, setData] = useState<ReportRow[]>([]);
const { showBoundary } = useErrorBoundary();
async function handleExport() {
try {
const csv = await generateExport(reportId);
downloadFile(csv, "report.csv");
} catch (error) {
// escalate to nearest ErrorBoundary instead of local error state
showBoundary(error);
}
}
return (
<div>
<button onClick={handleExport}>Export CSV</button>
{/* ... */}
</div>
);
}
Clean. The component doesn't need its own error state for errors it can't handle locally. It delegates up.
Where to Put Error Boundaries#
The answer we arrived at after the incident:
Page level — catches catastrophic failures. Shows "reload page." Every route should have one.
<ErrorBoundary FallbackComponent={PageCrashFallback} onError={logToSentry}>
<PageContent />
</ErrorBoundary>
Section level — independent data-loading sections. Dashboard widgets, sidebar panels, feed items. This is where most of your boundaries live.
<ErrorBoundary FallbackComponent={WidgetError} resetKeys={[widgetId]}>
<Suspense fallback={<WidgetSkeleton />}>
<Widget widgetId={widgetId} />
</Suspense>
</ErrorBoundary>
List item level — rare, but useful for feeds where one bad item shouldn't hide the rest:
function ActivityFeed({ events }: { events: ActivityEvent[] }) {
return (
<ul>
{events.map((event) => (
<ErrorBoundary key={event.id} FallbackComponent={EventItemError}>
<ActivityEventItem event={event} />
</ErrorBoundary>
))}
</ul>
);
}
What Error Boundaries Don't Catch#
This tripped us up twice:
- Event handler errors (use try/catch +
showBoundary) - Async errors like rejected Promises (use try/catch +
showBoundary) - Errors in the error boundary component itself (turtles all the way down)
- Server-side rendering errors
// This will NOT be caught by an ErrorBoundary
function BrokenButton() {
function handleClick() {
throw new Error("click handler error"); // nope, not caught
}
return <button onClick={handleClick}>Click me</button>;
}
// Fix: catch and delegate
function FixedButton() {
const { showBoundary } = useErrorBoundary();
function handleClick() {
try {
doRiskyThing();
} catch (error) {
showBoundary(error);
}
}
return <button onClick={handleClick}>Click me</button>;
}
The Resolution#
We shipped the AsyncSection wrapper pattern across the entire dashboard in two days. Added Sentry integration to the page-level boundaries. Added resetKeys to every route-level boundary.
Two weeks later, a different API endpoint returned unexpected data. One dashboard widget showed "Failed to load — Retry." Everything else kept working. Pablo sent a thumbs-up emoji in the monitoring channel.
That was worth more than any performance optimization I've ever shipped.
Error boundaries feel optional until you've been on the receiving end of a white screen incident. They're not optional. They're the difference between "one widget broke" and "the whole app is down for 2,000 people."