12 Context und useContext Hook

Die Kommunikation zwischen React-Komponenten erfolgt in der Regel über Props, wie wir in den vorhergehenden Kapiteln gelernt haben. Dieser Ansatz funktioniert hervorragend für direkte Eltern-Kind-Beziehungen und auch für wenige Ebenen der Komponentenhierarchie. Doch was passiert, wenn eine Komponente tief in der Hierarchie Daten benötigt, die nur ganz oben verfügbar sind?

Stellen Sie sich vor, Sie haben eine Anwendung mit einer Navigationsleiste, einem Hauptinhalt und einer Fußzeile. Die Navigationsleiste enthält einen Button zum Umschalten zwischen hellem und dunklem Theme. Sowohl der Hauptinhalt als auch die Fußzeile müssen auf dieses Theme reagieren. Ohne Context müssten Sie das Theme-State im obersten Komponenten verwalten und über Props durch alle Zwischenkomponenten hindurchreichen, auch wenn diese Zwischenkomponenten das Theme gar nicht benötigen.

12.1 Das Prop Drilling Problem

Dieses Phänomen des “Durchreichens” von Props durch Komponenten, die sie nicht selbst verwenden, nennt sich “Prop Drilling” oder “Props Threading”. Es ist eines der häufigsten Probleme in größeren React-Anwendungen und führt zu mehreren Schwierigkeiten.

Betrachten wir ein konkretes Beispiel: Eine E-Commerce-Anwendung mit einem Benutzerprofil, das in vielen verschiedenen Komponenten angezeigt werden muss. Ohne Context sieht die Prop-Weiterreichung folgendermaßen aus: Die App-Komponente lädt das Benutzerprofil, gibt es an die Layout-Komponente weiter, diese reicht es an die Header-Komponente weiter, von dort geht es an die Navigation, und schließlich landet es bei der UserDisplay-Komponente. Jede Zwischenkomponente muss das Benutzerprofil als Prop akzeptieren und weiterreichen, obwohl sie es nie verwendet.

Diese Architektur wird schnell unwartbar. Änderungen an der Datenstruktur des Benutzerprofils erfordern Anpassungen in allen Zwischenkomponenten. Neue Komponenten, die das Benutzerprofil benötigen, können nur hinzugefügt werden, wenn der gesamte Pfad von der Datenquelle zur neuen Komponente Props durchreicht. Die Typisierung wird komplex, da TypeScript-Interfaces für alle Zwischenkomponenten das weitergegebene Datum enthalten müssen.

12.2 Die Context-Lösung verstehen

React Context löst dieses Problem elegant, indem es einen “Übertragungskanal” für Daten schafft, der die normale Komponentenhierarchie umgeht. Stellen Sie sich Context wie ein unsichtbares Netzwerk vor, das durch Ihre gesamte Komponentenstruktur verläuft. Jede Komponente kann sich in dieses Netzwerk “einklinken” und Daten lesen oder schreiben, ohne dass die dazwischenliegenden Komponenten davon wissen müssen.

Das Context-System basiert auf drei Hauptkomponenten: dem Context-Objekt selbst, einem Provider, der Daten bereitstellt, und Konsumenten, die diese Daten verwenden. Der Provider fungiert als Datenquelle und umschließt alle Komponenten, die Zugriff auf die Context-Daten haben sollen. Die Konsumenten sind Komponenten, die mit dem useContext-Hook auf diese Daten zugreifen.

12.3 Context erstellen und typisieren

Die Erstellung eines Context beginnt mit der Definition der Datenstruktur. In TypeScript definieren wir zunächst ein Interface, das beschreibt, welche Daten und Funktionen der Context bereitstellen soll. Ein Theme-Context könnte beispielsweise das aktuelle Theme und eine Funktion zum Umschalten enthalten.

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

Anschließend erstellen wir den Context mit der createContext-Funktion. Diese Funktion erwartet einen Standardwert, der verwendet wird, falls eine Komponente den Context außerhalb eines Providers konsumiert. In der Praxis sollte dies nie passieren, wenn wir unsere Anwendung korrekt strukturieren, aber TypeScript und React benötigen dennoch einen Wert.

const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => console.warn('toggleTheme wurde außerhalb des Providers aufgerufen'),
});

Der Standardwert sollte dem definierten Typ entsprechen, aber funktional sollte er niemals in der echten Anwendung verwendet werden. Viele Entwickler verwenden eine leere Funktion oder werfen einen Fehler im Standardwert, um versehentliche Verwendung außerhalb des Providers zu verhindern.

12.4 Provider-Komponenten implementieren

Der Provider ist eine Komponente, die den Context-Wert verwaltet und an alle Kindkomponenten weitergibt. Sie fungiert als zentrale Datenquelle und implementiert die gesamte Geschäftslogik für den Context. Eine gut durchdachte Provider-Komponente kapselt nicht nur den Zustand, sondern auch alle Operationen, die auf diesem Zustand ausgeführt werden können.

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  const toggleTheme = useCallback(() => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  }, []);

  const contextValue = useMemo(() => ({
    theme,
    toggleTheme,
  }), [theme, toggleTheme]);

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}

Die Verwendung von useMemo für den Context-Wert ist eine wichtige Performance-Optimierung. Ohne useMemo würde bei jedem Re-Render der Provider-Komponente ein neues Objekt erstellt, was alle konsumierenden Komponenten zum Re-Rendering zwingen würde, auch wenn sich die eigentlichen Daten nicht geändert haben.

12.5 Custom Hooks für Context-Zugriff

Während Komponenten den Context direkt mit useContext konsumieren können, ist es eine bewährte Praxis, Custom Hooks zu erstellen, die den Context-Zugriff kapseln. Diese Hooks bieten mehrere Vorteile: Sie vereinfachen die Verwendung, ermöglichen zusätzliche Logik und Validierung, und sie können sicherstellen, dass der Context nur innerhalb der vorgesehenen Provider-Grenzen verwendet wird.

function useTheme() {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useTheme muss innerhalb eines ThemeProviders verwendet werden');
  }
  
  return context;
}

Diese Fehlerprüfung verhindert eine häufige Fehlerquelle: die versehentliche Verwendung von Context außerhalb des entsprechenden Providers. Ohne diese Prüfung würden Komponenten den Standardwert des Context verwenden, was zu schwer nachvollziehbaren Bugs führen kann.

12.6 Context in Komponenten verwenden

Die Verwendung von Context in Komponenten erfolgt über den Custom Hook, den wir definiert haben. Der Hook gibt das Context-Objekt zurück, aus dem wir die benötigten Werte und Funktionen destrukturieren können. Dies funktioniert genauso wie die Verwendung von useState oder anderen Hooks.

function Header() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <header style={{ 
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#333' : '#fff'
    }}>
      <h1>Meine App</h1>
      <button onClick={toggleTheme}>
        Theme wechseln
      </button>
    </header>
  );
}

Die Komponente erhält automatisch Updates, wenn sich der Context-Wert ändert. React behandelt Context-Updates genauso wie State-Updates: Alle Komponenten, die den Context konsumieren, werden neu gerendert, wenn sich der Wert ändert.

12.7 Komplexere Context-Strukturen

Während ein einfacher Theme-Context ein guter Einstieg ist, benötigen reale Anwendungen oft komplexere Context-Strukturen. Ein Shopping-Cart-Context könnte beispielsweise eine Liste von Produkten, Funktionen zum Hinzufügen und Entfernen von Artikeln, sowie Berechnungen für Gesamtpreise enthalten.

Bei komplexeren Contexts wird die Trennung von Concerns besonders wichtig. Der Provider sollte die gesamte Geschäftslogik enthalten, während die konsumierenden Komponenten nur die Präsentationslogik implementieren. Dies führt zu besserer Testbarkeit und Wiederverwendbarkeit.

interface CartContextType {
  items: CartItem[];
  addToCart: (product: Product) => void;
  removeFromCart: (productId: number) => void;
  updateQuantity: (productId: number, quantity: number) => void;
  getTotalPrice: () => number;
  getTotalItems: () => number;
  clearCart: () => void;
}

Solche umfangreicheren Contexts erfordern sorgfältige Planung der Datenstruktur und der bereitgestellten Operationen. Jede Funktion sollte eine klare Verantwortlichkeit haben und konsistent mit den anderen Operationen sein.

12.8 Context Composition und Provider-Hierarchien

In größeren Anwendungen benötigen Sie wahrscheinlich mehrere Contexts für verschiedene Aspekte Ihrer Anwendung. Ein Theme-Context, ein Benutzer-Context, ein Shopping-Cart-Context und ein Benachrichtigungs-Context könnten alle gleichzeitig existieren. React erlaubt es, mehrere Provider zu verschachteln oder zu komponieren.

function App() {
  return (
    <ThemeProvider>
      <UserProvider>
        <CartProvider>
          <NotificationProvider>
            <Router>
              <Routes>
                {/* Ihre Routen */}
              </Routes>
            </Router>
          </NotificationProvider>
        </CartProvider>
      </UserProvider>
    </ThemeProvider>
  );
}

Diese Verschachtelung kann bei vielen Providern unübersichtlich werden. Eine elegante Lösung ist die Erstellung einer Compose-Komponente, die mehrere Provider automatisch verschachtelt, oder die Verwendung einer Provider-Factory-Funktion.

12.9 Performance-Überlegungen bei Context

Context ist ein mächtiges Werkzeug, aber es muss mit Bedacht eingesetzt werden, um Performance-Probleme zu vermeiden. Jede Änderung an einem Context-Wert löst Re-Renders in allen konsumierenden Komponenten aus, unabhängig davon, welcher Teil des Context-Werts tatsächlich geändert wurde.

Wenn Sie einen Context haben, der sowohl häufig ändernde Daten (wie einen Counter) als auch selten ändernde Daten (wie Benutzerinformationen) enthält, führt jede Counter-Aktualisierung zu Re-Renders aller Komponenten, die den Context verwenden, auch wenn sie nur die Benutzerinformationen benötigen.

Die Lösung für dieses Problem ist die Aufspaltung in mehrere spezifische Contexts. Ein Counter-Context und ein User-Context würden unabhängig voneinander Updates auslösen. Komponenten, die nur Benutzerinformationen benötigen, würden nicht von Counter-Updates betroffen sein.

Ein weiterer Ansatz ist die Verwendung von useSelector-ähnlichen Patterns, bei denen Komponenten nur bestimmte Teile des Context abonnieren. Dies erfordert jedoch zusätzliche Komplexität in der Implementierung und ist oft nur in sehr performance-kritischen Anwendungen notwendig.

12.10 Häufige Fehler und deren Vermeidung

Ein häufiger Hehler ist die Verwendung von Context für alle Daten, auch für solche, die nur zwischen direkten Eltern-Kind-Komponenten ausgetauscht werden. Context sollte nur für Daten verwendet werden, die wirklich über mehrere Ebenen der Komponentenhierarchie hinweg geteilt werden müssen. Für einfache Eltern-Kind-Kommunikation sind Props nach wie vor die bessere Wahl.

Ein weiterer Fehler ist die Erstellung zu großer Context-Objekte. Ein einzelner “App-Context” mit allen globalen Daten mag verlockend erscheinen, führt aber zu den bereits erwähnten Performance-Problemen und macht die Anwendung schwer wartbar. Stattdessen sollten logisch zusammengehörige Daten in separate Contexts aufgeteilt werden.

Die fehlende Typisierung von Context-Werten ist ein weiteres Problem, besonders in TypeScript-Projekten. Ohne explizite Typen verlieren Sie die Vorteile der statischen Typprüfung und erschweren die Entwicklung. Investieren Sie Zeit in die ordentliche Typisierung Ihrer Context-Interfaces.

12.11 Integration mit bestehenden State Management Lösungen

Context ersetzt nicht alle State Management Bibliotheken. Für sehr komplexe Zustandsverwaltung mit vielen ineinandergreifenden Updates, optimistischen Updates oder komplexen Caching-Anforderungen sind spezialisierte Bibliotheken wie Redux Toolkit oder Zustand oft besser geeignet.

Context eignet sich hervorragend für relativ statische globale Daten wie Theme-Einstellungen, Benutzerinformationen oder Lokalisierungseinstellungen. Es ist auch eine gute Wahl für Anwendungsteile, die eine eigene, isolierte Zustandsverwaltung benötigen, wie modale Dialoge oder Form-Wizards.

Die Entscheidung zwischen Context und einer externen State Management Bibliothek sollte auf der Komplexität Ihrer Zustandslogik, der Häufigkeit der Updates und den spezifischen Anforderungen Ihrer Anwendung basieren. Context ist in React eingebaut und erfordert keine zusätzlichen Dependencies, was für viele Anwendungen ein entscheidender Vorteil ist.

12.12 Debugging und Entwicklungstools

React DevTools bieten ausgezeichnete Unterstützung für Context-Debugging. Sie können den aktuellen Wert aller Context-Provider in der Komponentenhierarchie sehen und nachverfolgen, welche Komponenten welche Contexts konsumieren. Dies ist besonders hilfreich beim Debugging von Performance-Problemen oder unerwartetem Re-Rendering-Verhalten.

Für die Entwicklung können Sie auch Context-Display-Namen setzen, die in den DevTools angezeigt werden. Dies macht es einfacher, verschiedene Contexts in komplexen Anwendungen zu unterscheiden.

ThemeContext.displayName = 'ThemeContext';