We all know the classic React error boundary: wrap a component, catch rendering errors, and show a fallback UI. But if you’ve worked on real-world apps, you know the “textbook” approach often falls short. Async errors, route-specific crashes, and logging needs require a more advanced setup.
In this article, I’ll walk you through a TypeScript-first approach to error boundaries that makes your React apps more resilient, easier to debug, and more user-friendly.
1. The Strongly Typed Error Boundary
First, let’s define a solid, TypeScript-friendly error boundary that handles errors gracefully and logs them:
import React from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class AppErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Log to monitoring service
console.error("Logged Error:", error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
This gives you type safety for both props and state while keeping your error handling centralized.
2. Route-Level Boundaries: Isolate the Crashes
Instead of one giant boundary at the root of your app, wrap specific routes or features. This way, a single failing page doesn’t crash your whole app:
import { AppErrorBoundary } from "./AppErrorBoundary";
import Dashboard from "./Dashboard";
function DashboardRoute() {
return (
<AppErrorBoundary fallback={<h2>Dashboard failed to load.</h2>}>
<Dashboard />
</AppErrorBoundary>
);
}
Users can still navigate your app, even if one page has an error.
3. Handling Async and Event Errors
React error boundaries don’t catch errors in async/await
or event handlers. To fix this, wrap your async functions:
function safeAsync<T extends (...args: any[]) => Promise<any>>(fn: T) {
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return await fn(...args);
} catch (err) {
console.error("Async error:", err);
throw err; // optional: let ErrorBoundary catch it if needed
}
};
}
// Usage
const handleClick = safeAsync(async () => {
throw new Error("Boom!");
});
<button onClick={handleClick}>Click Me</button>;
This ensures async crashes are logged and can be optionally caught by your boundary.
4. Resettable Boundaries: Let Users Recover
A frozen fallback UI is frustrating. With react-error-boundary
, you can provide a retry button:
import { ErrorBoundary } from "react-error-boundary";
function Fallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
);
}
<ErrorBoundary
FallbackComponent={Fallback}
onError={(error) => console.error("Caught by boundary:", error)}
resetKeys={[/* state/props that trigger reset */]}
>
<Dashboard />
</ErrorBoundary>
Users can recover without having to refresh the page.
5. Layered Approach for Production-Ready Apps
Combine these strategies for a robust setup:
- Global boundary - catches catastrophic failures.
- Route/component boundaries - isolate crashes.
- Async wrappers + logging - capture what React misses.
- Resettable fallbacks - improve user experience.
This layered approach keeps your app resilient and your users happy.
Wrapping Up
React’s built-in error boundaries are just the starting point. In real apps, you need a TypeScript-first, layered strategy:
- Strong typing for safety
- Logging for observability
- Isolation for reliability
- Recovery for UX
This way, errors are no longer showstoppers, they’re just part of a manageable system.
If you enjoyed this, check out my other articles for more advanced, production-ready patterns.
Top comments (0)