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)