--- name: error-boundary description: React error boundary hierarchy, fallback UI patterns, offline-first fallback, retry mechanisms, and graceful degradation. --- # Error Boundary Patterns React error boundary hierarchy and graceful degradation patterns. ## Error Boundary Component ```typescript // Class-based (React requirement for componentDidCatch) interface ErrorBoundaryProps { children: React.ReactNode fallback?: React.ComponentType onError?: (error: Error, info: React.ErrorInfo) => void } interface ErrorBoundaryState { error: Error | null } export interface FallbackProps { error: Error reset: () => void } export class ErrorBoundary extends React.Component { state: ErrorBoundaryState = { error: null } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { error } } componentDidCatch(error: Error, info: React.ErrorInfo) { console.error('[ErrorBoundary]', error, info.componentStack) this.props.onError?.(error, info) } reset = () => this.setState({ error: null }) render() { if (this.state.error) { const Fallback = this.props.fallback ?? DefaultFallback return } return this.props.children } } // react-error-boundary library (recommended — battle-tested) // npm install react-error-boundary import { ErrorBoundary } from 'react-error-boundary' reportToSentry(error, info)} onReset={() => queryClient.clear()} > ``` ## Error Boundary Hierarchy ``` App (top-level — catches everything, shows full-page error) ├── Navigation (no boundary — nav errors bubble to app) ├── ← page level │ └── DashboardPage │ ├── ← section level │ │ └── StatsSection │ └── ← widget level │ └── ChartWidget (one fails, others work) └── Sidebar (separate boundary — sidebar error ≠ main content error) ``` ```typescript // Page-level boundary (reset on navigation) import { usePathname } from 'next/navigation' import { ErrorBoundary } from 'react-error-boundary' export function PageErrorBoundary({ children }: { children: React.ReactNode }) { const pathname = usePathname() return ( {children} ) } // Widget-level boundary (isolated, inline fallback) export function WidgetErrorBoundary({ children, name }: { children: React.ReactNode; name: string }) { return ( } onError={(err) => reportError(err, { widget: name })} > {children} ) } ``` ## Fallback UI Design Patterns ```typescript // Full-page fallback (app boundary) function PageErrorFallback({ error, reset }: FallbackProps) { return (

Something went wrong

{process.env.NODE_ENV === 'development' ? error.message : 'An unexpected error occurred. Our team has been notified.'}

Go home
{process.env.NODE_ENV === 'development' && (
          {error.stack}
        
)}
) } // Inline section fallback (section boundary) function SectionErrorFallback({ error, reset }: FallbackProps) { return (

Failed to load this section

) } // Toast-style fallback (non-critical widget) function WidgetErrorFallback({ name }: { name: string }) { return (
{name} unavailable
) } ``` ## Retry Mechanism in Error Boundaries ```typescript import { ErrorBoundary } from 'react-error-boundary' import { useState, useCallback } from 'react' function RetryFallback({ error, reset }: FallbackProps) { const [retries, setRetries] = useState(0) const maxRetries = 3 const handleRetry = useCallback(() => { if (retries < maxRetries) { setRetries(r => r + 1) reset() } }, [retries, reset]) return (

Failed to load

{retries < maxRetries ? ( ) : (

Please refresh the page or contact support.

)}
) } ``` ## Error Reporting (Sentry Integration) ```typescript import * as Sentry from '@sentry/nextjs' export function reportError( error: Error, context?: Record, info?: React.ErrorInfo ) { if (process.env.NODE_ENV === 'development') { console.error('[Error]', error, context) return } Sentry.withScope(scope => { if (context) scope.setExtras(context) if (info?.componentStack) scope.setExtra('componentStack', info.componentStack) Sentry.captureException(error) }) } ``` ## Offline Detection and Fallback UI ```typescript 'use client' import { useState, useEffect } from 'react' function useOnlineStatus() { const [isOnline, setIsOnline] = useState( typeof window !== 'undefined' ? navigator.onLine : true ) useEffect(() => { const on = () => setIsOnline(true) const off = () => setIsOnline(false) window.addEventListener('online', on) window.addEventListener('offline', off) return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off) } }, []) return isOnline } export function OfflineBanner() { const isOnline = useOnlineStatus() if (isOnline) return null return (
You are offline. Changes will sync when connection is restored.
) } ``` ## Partial Failure Handling ```typescript // Multiple independent widgets — one fails, others keep working export function Dashboard() { return (
) } // Graceful degradation: show stale data when fetch fails async function StatsWidget() { try { const stats = await fetchStats() return } catch { const cached = await getCachedStats() if (cached) return throw new Error('Stats unavailable') // let error boundary handle } } ``` ## Error Recovery (Reset on Navigation) ```typescript import { usePathname } from 'next/navigation' import { useEffect, useRef } from 'react' import { useErrorBoundary } from 'react-error-boundary' function NavigationErrorReset() { const pathname = usePathname() const { resetBoundary } = useErrorBoundary() const prevPath = useRef(pathname) useEffect(() => { if (prevPath.current !== pathname) { prevPath.current = pathname resetBoundary() } }, [pathname, resetBoundary]) return null } // Place inside ErrorBoundary to auto-reset on navigation {children} ``` ## Development vs Production Error Display ```typescript function ErrorFallback({ error, reset }: FallbackProps) { const isDev = process.env.NODE_ENV === 'development' return (

{isDev ? `Error: ${error.name}` : 'Something went wrong'}

{isDev ? (
Stack trace
{error.stack}
) : (

An unexpected error occurred. Please try again.

)}
) } ``` ## Testing Error Boundaries ```typescript import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ErrorBoundary } from 'react-error-boundary' function BrokenComponent({ shouldThrow }: { shouldThrow: boolean }) { if (shouldThrow) throw new Error('Test error') return
Working fine
} describe('ErrorBoundary', () => { beforeEach(() => vi.spyOn(console, 'error').mockImplementation(() => {})) afterEach(() => vi.mocked(console.error).mockRestore()) it('shows fallback UI when child throws', () => { render( ) expect(screen.getByRole('alert')).toBeInTheDocument() expect(screen.getByText(/something went wrong/i)).toBeInTheDocument() }) it('resets and shows content after retry', async () => { const user = userEvent.setup() let shouldThrow = true function Toggle() { if (shouldThrow) throw new Error('Test error') return
Recovered
} render() shouldThrow = false await user.click(screen.getByRole('button', { name: /try again/i })) expect(screen.getByText('Recovered')).toBeInTheDocument() }) }) ``` ## Decision Matrix | Scope | Fallback Type | When | |-------|--------------|------| | Widget | Inline placeholder | Non-critical UI section | | Section | Collapsed + retry button | Independent feature area | | Page | Full page with retry | Page-level data failure | | App | Full page with refresh | Unrecoverable state | | Network | Toast + offline banner | Connectivity issues | --- ## Original Patterns (preserved for reference) ## Basic Error Boundary (react-error-boundary) ```tsx import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { return (

Something went wrong

{error.message}
); } // Usage ``` ## Error Boundary Hierarchy ``` App Error Boundary (full page fallback) ├── Layout Error Boundary (layout-level recovery) │ ├── Sidebar Error Boundary (sidebar collapses gracefully) │ └── Main Content Error Boundary │ ├── Widget A Error Boundary (individual widget fails) │ ├── Widget B Error Boundary │ └── Widget C Error Boundary ``` ```tsx // App-level: full page fallback with navigation {/* Section-level: isolated failures */} ``` ## Next.js App Router (error.tsx) ```tsx // app/dashboard/error.tsx 'use client'; export default function DashboardError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { // Log to error reporting service reportError(error); }, [error]); return (

Dashboard could not load

{error.message}

); } ``` ## Fallback UI Patterns ```tsx // 1. Inline fallback (widget level) function InlineFallback({ error }: { error: Error }) { return (
Unable to load this section
); } // 2. Toast notification (non-critical) function ToastFallback({ error, resetErrorBoundary }: FallbackProps) { useEffect(() => { toast.error(error.message, { action: { label: 'Retry', onClick: resetErrorBoundary }, }); }, [error]); return null; // renders nothing, shows toast instead } // 3. Full page (critical) function FullPageFallback({ error, resetErrorBoundary }: FallbackProps) { return (

Something went wrong

Please try refreshing the page

); } ``` ## Retry with Error Boundary ```tsx import { ErrorBoundary } from 'react-error-boundary'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; // Auto-reset on navigation { // Clear any cached state that caused the error queryClient.invalidateQueries(); }} resetKeys={[pathname]} // auto-reset when URL changes > // With TanStack Query {({ reset }) => ( )} ``` ## Error Reporting ```tsx function logError(error: Error, info: { componentStack?: string }) { // Send to Sentry/monitoring if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') { Sentry.captureException(error, { extra: { componentStack: info.componentStack }, }); } } ``` ## Offline Detection ```tsx function useOnlineStatus() { const [isOnline, setIsOnline] = useState( typeof navigator !== 'undefined' ? navigator.onLine : true ); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOnline; } // Offline banner function OfflineBanner() { const isOnline = useOnlineStatus(); if (isOnline) return null; return (
You are offline. Some features may be unavailable.
); } ``` ## Testing Error Boundaries ```tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; function ThrowError() { throw new Error('Test error'); } test('renders fallback on error', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); render( ); expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByText(/test error/i)).toBeInTheDocument(); spy.mockRestore(); }); test('resets on retry click', async () => { let shouldThrow = true; function MaybeThrow() { if (shouldThrow) throw new Error('Boom'); return
Recovered
; } render( ); shouldThrow = false; await userEvent.click(screen.getByText('Try again')); expect(screen.getByText('Recovered')).toBeInTheDocument(); }); ``` ## Decision Matrix | Scope | Fallback Type | When | |-------|--------------|------| | Widget | Inline placeholder | Non-critical UI section | | Section | Collapsed + retry button | Independent feature area | | Page | Full page with retry | Page-level data failure | | App | Full page with refresh | Unrecoverable state | | Network | Toast + offline banner | Connectivity issues |