37 Fehlerbehandlung – Error Boundaries und Fallback-Strategien

React-Komponenten können fehlschlagen. Ein API-Call liefert unerwartete Daten, eine Bibliothek wirft eine Exception, ein Entwickler macht einen Tippfehler in einer Template-Expression. Ohne Fehlerbehandlung bedeutet ein einziger Fehler in einer Komponente oft: weiße Seite, keine UI, frustrierte Nutzer. React bietet mit Error Boundaries ein Konzept, das verhindert, dass einzelne Fehler die gesamte Anwendung lahmlegen.

37.1 Das Grundproblem: Fehler brechen das Rendering

Ein Fehler während des Renderings stoppt React komplett. Die Komponenten-Hierarchie wird nicht weiter aufgebaut, das DOM bleibt unvollständig oder verschwindet ganz.

function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(product => (
        <div key={product.id}>
          {product.name.toUpperCase()}  {/* Was wenn product.name undefined? */}
          {product.price.toFixed(2)}     {/* Was wenn price keine Zahl? */}
        </div>
      ))}
    </div>
  );
}

Ein einziges undefined bei product.name wirft einen TypeError. React kann nicht weiter rendern, die komplette UI verschwindet. In Production sieht der Nutzer eine leere Seite. In Development zeigt React einen Error-Overlay – hilfreich für Entwickler, katastrophal für Endnutzer.

Traditionelles try-catch hilft hier nicht. Es funktioniert nur für imperativen Code, nicht für deklaratives Rendering.

// ❌ Funktioniert nicht für Render-Fehler
function ProductList({ products }: { products: Product[] }) {
  try {
    return (
      <div>
        {products.map(product => (
          <div>{product.name.toUpperCase()}</div>
        ))}
      </div>
    );
  } catch (error) {
    return <div>Error!</div>;  // Wird nie erreicht
  }
}

Der try-Block umschließt den Return-Statement, aber der Fehler passiert später – während React das JSX auswertet. Zu diesem Zeitpunkt ist die Funktion längst verlassen.

37.2 Error Boundaries: Reacts Fehler-Sicherheitsnetz

Error Boundaries sind spezielle Komponenten, die Fehler in ihrer gesamten Child-Hierarchie abfangen können. Sie sind eines der letzten Use-Cases, wo Klassen-Komponenten unverzichtbar sind – die entsprechenden Lifecycle-Methoden existieren für Function Components nicht.

import React, { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error: Error): State {
    // Wird während Render-Phase aufgerufen
    return { hasError: true, error };
  }
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Wird während Commit-Phase aufgerufen
    console.error('Error caught by boundary:', error, errorInfo);
    
    // Hier könnte man zu Sentry, LogRocket, etc. loggen
    // logErrorToService(error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div>
          <h2>Etwas ist schiefgelaufen</h2>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Erneut versuchen
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// Verwendung
function App() {
  return (
    <ErrorBoundary>
      <ProductList products={products} />
    </ErrorBoundary>
  );
}

Zwei Lifecycle-Methoden machen eine Klasse zur Error Boundary:

getDerivedStateFromError wird synchron während des Renderings aufgerufen. Sie erhält den Fehler und gibt neuen State zurück. Keine Side-Effects erlaubt – nur State-Update für Fallback-UI.

componentDidCatch wird asynchron nach dem Rendering aufgerufen. Hier gehören Side-Effects hin: Logging, Analytics, Error-Reporting.

Error Boundaries fangen Fehler in: - Render-Methoden von Child-Komponenten - Lifecycle-Methoden - Konstruktoren der gesamten Hierarchie darunter

Sie fangen NICHT Fehler in: - Event-Handlern - Asynchronem Code (setTimeout, Promises, async/await) - Server-Side Rendering - Der Error Boundary selbst

37.3 Granulare Fehlerbehandlung: Mehrere Boundaries

Eine einzelne Error Boundary um die gesamte App ist zu grob. Ein Fehler in der Sidebar würde den Main Content zerstören. Besser: Mehrere Boundaries auf verschiedenen Ebenen.

function App() {
  return (
    <ErrorBoundary fallback={<FullPageError />}>
      <Header />
      
      <div className="layout">
        <ErrorBoundary fallback={<SidebarError />}>
          <Sidebar />
        </ErrorBoundary>
        
        <main>
          <ErrorBoundary fallback={<ContentError />}>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/products" element={
                <ErrorBoundary fallback={<ProductListError />}>
                  <ProductList />
                </ErrorBoundary>
              } />
            </Routes>
          </ErrorBoundary>
        </main>
      </div>
    </ErrorBoundary>
  );
}
Boundary-Ebene Zweck Fallback
Root Letzte Verteidigung Generische Fehlerseite mit Reload
Feature Isoliert Features Feature-spezifische Meldung
Komponente Granulare Isolation Minimaler Placeholder

Fehler in der Sidebar lassen Main Content intakt. Fehler in einer Produkt-Karte betreffen nicht die Liste.

37.4 Event-Handler: try-catch manuell

Error Boundaries fangen keine Event-Handler-Fehler. Diese sind synchron und React hat bereits gerendert, wenn sie ausgeführt werden.

function ProductCard({ product }: { product: Product }) {
  // ❌ Error Boundary fängt das nicht
  const handleClick = () => {
    throw new Error('Click handler error');
  };
  
  // ✓ Manuelles try-catch
  const handleClickSafe = () => {
    try {
      // Potentiell fehleranfällige Logik
      processProduct(product);
    } catch (error) {
      console.error('Error processing product:', error);
      // Fehler in State speichern für UI-Feedback
      setError(error);
    }
  };
  
  return <button onClick={handleClickSafe}>Process</button>;
}

Für asynchronen Code in Event-Handlern:

function ProductForm() {
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(false);
  
  const handleSubmit = async (data: FormData) => {
    setLoading(true);
    setError(null);
    
    try {
      await saveProduct(data);
      // Success-Logik
    } catch (error) {
      setError(error instanceof Error ? error : new Error(String(error)));
    } finally {
      setLoading(false);
    }
  };
  
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  
  return <form onSubmit={handleSubmit}>...</form>;
}

37.5 Custom Error Boundary mit Context

Für komplexere Szenarien: Error Boundary mit Context für globales Error-Management.

interface ErrorContextType {
  error: Error | null;
  resetError: () => void;
  setError: (error: Error) => void;
}

const ErrorContext = createContext<ErrorContextType | undefined>(undefined);

export function useError() {
  const context = useContext(ErrorContext);
  if (!context) {
    throw new Error('useError must be used within ErrorBoundaryProvider');
  }
  return context;
}

interface ErrorBoundaryProviderProps {
  children: ReactNode;
  fallback?: (error: Error, reset: () => void) => ReactNode;
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

export class ErrorBoundaryProvider extends Component
  ErrorBoundaryProviderProps,
  State
> {
  constructor(props: ErrorBoundaryProviderProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.props.onError?.(error, errorInfo);
  }
  
  resetError = () => {
    this.setState({ hasError: false, error: null });
  };
  
  setError = (error: Error) => {
    this.setState({ hasError: true, error });
  };
  
  render() {
    const { children, fallback } = this.props;
    const { hasError, error } = this.state;
    
    const contextValue: ErrorContextType = {
      error,
      resetError: this.resetError,
      setError: this.setError
    };
    
    return (
      <ErrorContext.Provider value={contextValue}>
        {hasError && error
          ? fallback?.(error, this.resetError) || <DefaultErrorUI error={error} />
          : children
        }
      </ErrorContext.Provider>
    );
  }
}

// Verwendung in Event-Handlern
function ProductCard({ product }: { product: Product }) {
  const { setError } = useError();
  
  const handleDelete = async () => {
    try {
      await deleteProduct(product.id);
    } catch (error) {
      // Fehler wird von Error Boundary gefangen und angezeigt
      setError(error instanceof Error ? error : new Error(String(error)));
    }
  };
  
  return <button onClick={handleDelete}>Delete</button>;
}

37.6 Suspense und Lazy Loading

Suspense ist Reacts Mechanismus für asynchrones Rendering – hauptsächlich für Code-Splitting mit React.lazy.

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/products" element={<Products />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </ErrorBoundary>
  );
}

Suspense zeigt den Fallback, während die Komponente lädt. Error Boundary fängt Fehler beim Laden (z.B. Netzwerk-Fehler, Parse-Fehler).

Kombiniert mit Error Boundaries:

function LazyRoute({ 
  component: Component,
  fallback = <LoadingSpinner />,
  errorFallback = <RouteError />
}: LazyRouteProps) {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={fallback}>
        <Component />
      </Suspense>
    </ErrorBoundary>
  );
}

// Verwendung
<Route path="/products" element={
  <LazyRoute 
    component={lazy(() => import('./Products'))}
    fallback={<ProductsLoading />}
    errorFallback={<ProductsError />}
  />
} />

37.7 Production vs. Development

In Development zeigt React detaillierte Error-Overlays. In Production sind diese deaktiviert – Error Boundaries sind die einzige Fehler-UI.

interface ErrorFallbackProps {
  error: Error;
  resetError: () => void;
}

function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
  const isDev = process.env.NODE_ENV === 'development';
  
  return (
    <div className="error-container">
      <h2>Etwas ist schiefgelaufen</h2>
      
      {isDev && (
        <details>
          <summary>Fehlerdetails (nur in Development sichtbar)</summary>
          <pre>{error.message}</pre>
          <pre>{error.stack}</pre>
        </details>
      )}
      
      <button onClick={resetError}>
        Seite neu laden
      </button>
      
      <button onClick={() => window.location.href = '/'}>
        Zur Startseite
      </button>
    </div>
  );
}

37.8 Error Logging und Monitoring

Error Boundaries sind der ideale Integrationspunkt für Error-Tracking-Services.

import * as Sentry from '@sentry/react';

class ErrorBoundary extends Component<Props, State> {
  // ... state und getDerivedStateFromError
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Zu Sentry loggen
    Sentry.captureException(error, {
      contexts: {
        react: {
          componentStack: errorInfo.componentStack
        }
      }
    });
    
    // Zusätzliche Custom-Daten
    Sentry.setContext('user', {
      id: getCurrentUserId(),
      route: window.location.pathname
    });
  }
  
  render() {
    // ... Fallback-UI
  }
}

Alternativ bietet Sentry eine fertige Error Boundary:

import { ErrorBoundary } from '@sentry/react';

function App() {
  return (
    <ErrorBoundary 
      fallback={<ErrorPage />}
      showDialog  // Zeigt Feedback-Dialog
    >
      <YourApp />
    </ErrorBoundary>
  );
}

37.9 Testing von Error Boundaries

Error Boundaries zu testen ist trickreich, da React in Tests Fehler anders behandelt. React Testing Library bietet Utilities:

import { render, screen } from '@testing-library/react';

// Komponente die einen Fehler wirft
function BrokenComponent() {
  throw new Error('Test error');
}

test('Error Boundary catches errors', () => {
  // Console.error mocken, da React Fehler loggt
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  
  render(
    <ErrorBoundary fallback={<div>Error occurred</div>}>
      <BrokenComponent />
    </ErrorBoundary>
  );
  
  expect(screen.getByText('Error occurred')).toBeInTheDocument();
  
  spy.mockRestore();
});

test('Error Boundary resets', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  const { rerender } = render(
    <ErrorBoundary>
      <BrokenComponent />
    </ErrorBoundary>
  );
  
  expect(screen.getByText(/error/i)).toBeInTheDocument();
  
  // Simuliere Reset
  rerender(
    <ErrorBoundary>
      <div>Normal content</div>
    </ErrorBoundary>
  );
  
  expect(screen.getByText('Normal content')).toBeInTheDocument();
  
  spy.mockRestore();
});

37.10 Praktische Patterns

Pattern 1: Reset bei Route-Wechsel

import { useLocation } from 'react-router-dom';

function RouterErrorBoundary({ children }: { children: ReactNode }) {
  const location = useLocation();
  const [key, setKey] = useState(location.pathname);
  
  useEffect(() => {
    // Reset Error Boundary bei Route-Wechsel
    setKey(location.pathname);
  }, [location.pathname]);
  
  return (
    <ErrorBoundary key={key}>
      {children}
    </ErrorBoundary>
  );
}

Pattern 2: Retry-Logik

class RetryErrorBoundary extends Component<Props, State> {
  state = { hasError: false, error: null, retryCount: 0 };
  
  static getDerivedStateFromError(error: Error): Partial<State> {
    return { hasError: true, error };
  }
  
  retry = () => {
    this.setState(prev => ({
      hasError: false,
      error: null,
      retryCount: prev.retryCount + 1
    }));
  };
  
  render() {
    if (this.state.hasError) {
      const canRetry = this.state.retryCount < 3;
      
      return (
        <div>
          <p>Error: {this.state.error?.message}</p>
          {canRetry ? (
            <button onClick={this.retry}>
              Erneut versuchen ({3 - this.state.retryCount} übrig)
            </button>
          ) : (
            <p>Maximale Versuche erreicht</p>
          )}
        </div>
      );
    }
    
    return <>{this.props.children}</>;
  }
}

Pattern 3: Feature-Flags für Fehler-UI

function FeatureFlaggedBoundary({ children }: { children: ReactNode }) {
  const { isEnabled } = useFeatureFlag('detailed-errors');
  
  return (
    <ErrorBoundary
      fallback={(error, reset) => 
        isEnabled ? (
          <DetailedError error={error} reset={reset} />
        ) : (
          <SimpleError reset={reset} />
        )
      }
    >
      {children}
    </ErrorBoundary>
  );
}

37.11 Häufige Fehler

Fehler 1: Annahme, Error Boundaries fangen alles

// ❌ Async-Fehler werden NICHT gefangen
function ProductList() {
  useEffect(() => {
    fetchProducts()
      .then(setProducts)
      .catch(error => {
        // Error Boundary fängt das NICHT
        throw error;  // Wird nicht propagiert!
      });
  }, []);
}

// ✓ Eigenes Error-Handling
function ProductList() {
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetchProducts()
      .then(setProducts)
      .catch(setError);  // In State speichern
  }, []);
  
  if (error) {
    throw error;  // Jetzt kann Error Boundary fangen
  }
}

Fehler 2: Fehler in der Boundary selbst

// ❌ Wenn Fallback-UI fehlschlägt, gibt's keine Rettung
class ErrorBoundary extends Component {
  render() {
    if (this.state.hasError) {
      // Was wenn FallbackUI selbst fehlschlägt?
      return <ComplexFallbackThatMightFail error={this.state.error} />;
    }
    return this.props.children;
  }
}

// ✓ Sichere Fallback-UI
render() {
  if (this.state.hasError) {
    try {
      return this.props.fallback || <SafeMinimalFallback />;
    } catch {
      // Absolutes Minimum
      return <div>Critical Error</div>;
    }
  }
}

Fehler 3: getDerivedStateFromError mit Side-Effects

// ❌ Side-Effects in getDerivedStateFromError
static getDerivedStateFromError(error: Error) {
  logToSentry(error);  // ❌ Nicht hier!
  return { hasError: true, error };
}

// ✓ Side-Effects in componentDidCatch
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
  logToSentry(error, errorInfo);  // ✓ Hier!
}

Error Boundaries sind nicht perfekt – sie fangen nicht alles, sind Klassen-basiert in einer Hooks-Welt, und erfordern strategische Platzierung. Aber sie sind essentiell für produktionsreife React-Anwendungen. Kombiniert mit manuellem try-catch für async Code, Suspense für Lazy Loading und Error-Monitoring-Integration bilden sie ein robustes Fehlerbehandlungs-System.