16 useContext – Daten über Komponenten hinweg teilen

Props sind das Rückgrat der Komponentenkommunikation in React. Daten fließen von Parent zu Child, klar und nachvollziehbar. Dieses Modell funktioniert elegant – bis zu dem Moment, wo eine Komponente tief in der Hierarchie Daten benötigt, die nur ganz oben verfügbar sind. Plötzlich wird aus einem einfachen Datenfluss ein Staffellauf durch ein Dutzend Komponenten, von denen die meisten die Daten nur durchreichen, ohne sie selbst zu nutzen.

Das Problem hat einen Namen: Prop Drilling. Die Lösung ebenso: Context. Und der Mechanismus, um auf Context zuzugreifen, ist der useContext-Hook – ein Werkzeug, das globale Datenverteilung ermöglicht, ohne die Komponentenstruktur zu verschmutzen.

16.1 Das Prop Drilling Problem

Stellen wir uns eine E-Commerce-Anwendung vor. Die Nutzer-Authentifizierung erfolgt ganz oben in der App-Komponente. Informationen über den eingeloggten User – Name, Bild, Berechtigungen – werden in einem State gehalten. Diese Informationen werden an vielen Stellen benötigt: im Header für die User-Anzeige, im Warenkorb für die Checkout-Validierung, in Produktlisten für personalisierte Empfehlungen, im Footer für Support-Links.

Ohne Context sieht die Prop-Kette so aus:

function App() {
  const [user, setUser] = useState<User | null>(null);
  
  return <Layout user={user} />;
}

function Layout({ user }: { user: User | null }) {
  return (
    <>
      <Header user={user} />
      <MainContent user={user} />
      <Footer user={user} />
    </>
  );
}

function Header({ user }: { user: User | null }) {
  return <Navigation user={user} />;
}

function Navigation({ user }: { user: User | null }) {
  return <UserDisplay user={user} />;
}

function UserDisplay({ user }: { user: User | null }) {
  // Endlich! Die Komponente, die user tatsächlich nutzt
  return user ? <div>{user.name}</div> : <div>Nicht eingeloggt</div>;
}

Vier Komponenten reichen eine Prop weiter, ohne sie selbst zu nutzen. Layout, Header und Navigation sind reine Durchlauferhitzer. Ihre Props-Interfaces müssen user enthalten, obwohl sie nichts damit anfangen. Jede Änderung an der User-Datenstruktur erfordert Updates in allen Zwischenkomponenten. Das Hinzufügen neuer User-Informationen bedeutet, die gesamte Kette zu aktualisieren.

Bei kleinen Anwendungen ist das lästig. Bei großen ist es ein Wartungsproblem. Komponenten werden schwerer testbar, weil sie Props akzeptieren müssen, die sie nicht verwenden. Die Wiederverwendbarkeit leidet – eine Komponente kann nur dort eingesetzt werden, wo die gesamte Prop-Kette vorhanden ist.

Die grün markierten Komponenten verwenden user aktiv. Die roten reichen es nur durch. Das Verhältnis ist ungünstig.

16.2 Context: Ein Kommunikationskanal durch die Hierarchie

Context schafft eine Alternative zum Props-Fluss. Statt Daten durch die Hierarchie zu reichen, etabliert Context einen “Broadcast-Kanal”. Eine Komponente stellt Daten bereit (der Provider), und beliebige Komponenten darunter im Baum können diese Daten abrufen (die Consumer) – unabhängig davon, wie viele Ebenen dazwischen liegen.

Das Konzept ist nicht neu. Dependency Injection in anderen Frameworks funktioniert ähnlich. Ein Service wird an einer zentralen Stelle registriert, und Komponenten können ihn “injizieren” lassen, ohne den Pfad dorthin explizit zu kennen.

React’s Context-System basiert auf drei Komponenten:

Das Context-Objekt ist der Kanal selbst. Es wird einmal erstellt mit createContext und definiert die Struktur der Daten, die durchgereicht werden.

Der Provider ist eine Komponente, die Daten in den Kanal einspeist. Er umschließt alle Komponenten, die Zugriff auf die Daten haben sollen. Der Provider verwaltet typischerweise State und Logik.

Die Consumer sind Komponenten, die Daten aus dem Kanal lesen. Sie nutzen den useContext-Hook, um auf die aktuellen Werte zuzugreifen.

Die gestrichelte Linie zeigt den Context-Kanal. UserDisplay greift direkt auf Daten vom Provider zu, ohne dass die Zwischenkomponenten involviert sind.

16.3 Context erstellen: Struktur und Typen

Die Erstellung eines Context beginnt mit der Definition seiner Struktur. In TypeScript definieren wir ein Interface, das beschreibt, welche Daten und Funktionen der Context bereitstellt.

interface UserContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

Dieser Context bietet nicht nur den User-Wert, sondern auch Funktionen zum Login/Logout und einen abgeleiteten Wert isAuthenticated. Context kann State und Logik kapseln.

Die Context-Erstellung erfolgt mit createContext:

const UserContext = createContext<UserContextType | undefined>(undefined);

Der generische Typ-Parameter sagt TypeScript, welche Struktur der Context hat. Der Initialwert undefined mag überraschen – ist der Context nicht vom Typ UserContextType?

Die Nuance liegt darin, dass wir zwei Zustände unterscheiden müssen: Context innerhalb eines Providers (hat den vollen Typ) und Context außerhalb eines Providers (hat den Default-Wert). Mit undefined als Default signalisieren wir: “Wenn du diesen Context ohne Provider verwendest, ist das ein Fehler.”

Manche Entwickler verwenden stattdessen einen “dummy” Default-Wert:

const UserContext = createContext<UserContextType>({
  user: null,
  login: async () => { throw new Error('No Provider'); },
  logout: () => { throw new Error('No Provider'); },
  isAuthenticated: false
});

Dieser Ansatz erfüllt TypeScript’s Typsystem, macht aber Runtime-Fehler schwerer zu debuggen. Die Komponente scheint zu funktionieren (kein TypeScript-Fehler), schlägt aber zur Laufzeit fehl, wenn Funktionen aufgerufen werden.

Ich bevorzuge undefined mit expliziten Runtime-Checks – dazu gleich mehr.

16.4 Den Provider implementieren

Der Provider ist eine Komponente, die den Context-Wert verwaltet und bereitstellt. Sie kapselt State, Logik und stellt eine saubere API zur Verfügung.

function UserProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  
  const login = useCallback(async (credentials: Credentials) => {
    const userData = await api.login(credentials);
    setUser(userData);
  }, []);
  
  const logout = useCallback(() => {
    setUser(null);
  }, []);
  
  const isAuthenticated = user !== null;
  
  const value = useMemo(() => ({
    user,
    login,
    logout,
    isAuthenticated
  }), [user, login, logout, isAuthenticated]);
  
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

Mehrere Details sind wichtig:

State-Management erfolgt im Provider. user ist lokaler State, der mit useState verwaltet wird. Die Provider-Komponente ist der Single Source of Truth für User-Daten.

Callback-Funktionen sind mit useCallback memoiziert. Das ist nicht nur Performance-Optimierung, sondern Stabilität – Consumer, die diese Funktionen in Dependencies verwenden, sollen nicht bei jedem Rendering neue Referenzen erhalten.

Der Context-Wert ist mit useMemo memoiziert. Das ist kritisch. Ohne useMemo würde bei jedem Provider-Rendering ein neues Objekt erstellt. Alle Consumer würden re-rendern, selbst wenn sich die tatsächlichen Werte nicht geändert haben. useMemo garantiert stabile Referenzen, solange die Dependencies gleich bleiben.

Abgeleitete Werte wie isAuthenticated werden berechnet, nicht gespeichert. Sie sind konsistent mit dem User-State und erfordern keine separate Synchronisation.

Die Provider-Komponente wird hoch in der Komponentenhierarchie platziert, typischerweise in App oder nahe daran:

function App() {
  return (
    <UserProvider>
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/profile" element={<Profile />} />
        </Routes>
      </Router>
    </UserProvider>
  );
}

Alle Komponenten innerhalb des <UserProvider> können jetzt den User-Context konsumieren.

16.5 Context konsumieren: Der useContext-Hook

Der useContext-Hook ist die Consumer-Seite. Er nimmt ein Context-Objekt entgegen und gibt den aktuellen Wert zurück – den Wert, den der nächstliegende Provider im Baum bereitstellt.

function UserDisplay() {
  const context = useContext(UserContext);
  
  if (!context) {
    throw new Error('UserDisplay muss innerhalb von UserProvider verwendet werden');
  }
  
  const { user, logout } = context;
  
  return user ? (
    <div>
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  ) : (
    <div>Nicht eingeloggt</div>
  );
}

Der Null-Check ist wichtig. Weil wir den Context mit undefined als Default erstellt haben, kann useContext undefined zurückgeben, wenn kein Provider im Baum ist. Dieser Check fängt Fehler früh ab und gibt eine klare Fehlermeldung.

Die Komponente destrukturiert nur die Werte, die sie benötigt. Wenn sie nur user braucht, nicht login oder logout, kann sie das spezifizieren. Das macht die Dependency klar und den Code lesbarer.

16.6 Custom Hooks: API-Design für Context

Die Wiederholung des Null-Checks in jeder Consumer-Komponente ist redundant. Ein Custom Hook kapselt dieses Pattern:

function useUser() {
  const context = useContext(UserContext);
  
  if (!context) {
    throw new Error('useUser muss innerhalb von UserProvider verwendet werden');
  }
  
  return context;
}

Jetzt können Consumer einfach useUser aufrufen:

function UserDisplay() {
  const { user, logout } = useUser();
  
  return user ? (
    <div>
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  ) : (
    <div>Nicht eingeloggt</div>
  );
}

Sauberer, weniger Boilerplate, und der Null-Check ist zentral implementiert.

Custom Hooks bieten auch Raum für zusätzliche Logik. Ein Hook könnte nur bestimmte Teile des Context exponieren:

function useAuth() {
  const { login, logout, isAuthenticated } = useUser();
  return { login, logout, isAuthenticated };
}

function useCurrentUser() {
  const { user } = useUser();
  if (!user) throw new Error('Kein User eingeloggt');
  return user;
}

useAuth liefert nur Auth-bezogene Funktionen, nicht den User selbst. useCurrentUser geht weiter und wirft einen Error, wenn kein User eingeloggt ist – nützlich für Komponenten, die einen User voraussetzen.

16.7 Performance: Context-Updates und Re-Renderings

Context-Updates haben eine wichtige Eigenschaft: Jede Änderung am Context-Wert löst Re-Renderings in allen Consumer-Komponenten aus, unabhängig davon, welcher Teil des Werts sich geändert hat.

const value = {
  user,
  login,
  logout,
  isAuthenticated,
  settings  // Neues Feld
};

Wenn sich settings ändert, rendern alle Komponenten neu, die useUser aufrufen – auch wenn sie nur user verwenden und settings ignorieren. React vergleicht Context-Werte mit Object.is. Ein neues Objekt ist ein anderes Objekt, selbst wenn nur ein Feld geändert wurde.

Die Lösung liegt in der Aufteilung. Statt eines monolithischen Context mehrere spezifische Contexts:

// Separate Contexts für unabhängige Concerns
const UserContext = createContext<UserContextType | undefined>(undefined);
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);

Komponenten, die nur User benötigen, konsumieren UserContext. Komponenten, die nur Settings benötigen, konsumieren SettingsContext. Settings-Updates beeinflussen User-Consumer nicht und umgekehrt.

Ansatz User ändert sich Settings ändern sich User-Consumer rendert? Settings-Consumer rendert?
Ein Context Ja Nein Ja Ja
Ein Context Nein Ja Ja Ja
Zwei Contexts Ja Nein Ja Nein
Zwei Contexts Nein Ja Nein Ja

Die Granularität von Contexts ist eine Design-Entscheidung. Zu viele Contexts führen zu Provider-Verschachtelungen und Komplexität. Zu wenige führen zu unnötigen Re-Renderings. Die Balance liegt darin, logisch zusammengehörige Daten zu gruppieren und unabhängige Concerns zu trennen.

16.8 Mehrere Provider: Composition

Große Anwendungen benötigen mehrere Contexts. Theme, User, Shopping Cart, Notifications – jeder hat seinen eigenen Provider. Die Verschachtelung kann unübersichtlich werden:

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

Ein Compose-Pattern kann helfen:

function ComposeProviders({ providers, children }: {
  providers: Array<React.ComponentType<{ children: ReactNode }>>;
  children: ReactNode;
}) {
  return providers.reduceRight(
    (acc, Provider) => <Provider>{acc}</Provider>,
    children
  );
}

function App() {
  return (
    <ComposeProviders providers={[
      ThemeProvider,
      UserProvider,
      CartProvider,
      NotificationProvider
    ]}>
      <Router>
        {/* App */}
      </Router>
    </ComposeProviders>
  );
}

Die Provider werden in einem Array definiert und automatisch verschachtelt. Das reduziert die Verschachtelungs-Pyramide und macht die Provider-Struktur explizit.

16.9 Wann Context nicht verwenden

Context ist kein Allheilmittel. Es gibt Szenarien, wo Props die bessere Wahl sind:

Direkte Parent-Child-Kommunikation sollte über Props erfolgen. Wenn eine Komponente Daten an ihre unmittelbaren Children übergibt, ist Context Overhead ohne Nutzen. Props sind explizit, lokal und einfach zu verstehen.

Häufig wechselnde Werte passen schlecht zu Context. Ein Input-Feld, das bei jedem Tastendruck aktualisiert, sollte lokalen State verwenden. Context-Updates sind global – alle Consumer rendern neu. Für hochfrequente Updates ist das ineffizient.

Komponenten-spezifische State gehört in die Komponente. Nur weil mehrere Instanzen derselben Komponente existieren, heißt das nicht, dass sie State teilen müssen. Jede Instanz kann ihren eigenen State haben.

Context ist für Daten gedacht, die “wirklich global” sind – Dinge, die viele Komponenten an verschiedenen Stellen im Baum benötigen. Theme, User-Authentifizierung, Lokalisierung, globale Settings. Diese Daten ändern sich selten, werden aber breit konsumiert.

Für komplexeres State-Management – mit vielen Updates, ineinandergreifenden State-Transitions, optimistischen Updates – sind spezialisierte Libraries wie Redux Toolkit, Zustand oder Jotai oft besser geeignet. Sie bieten feinere Kontrolle über Re-Renderings, DevTools, Middleware und mehr.

16.10 Ein vollständiges Beispiel: Theme-Context

Bringen wir alle Teile zusammen in einem vollständigen Theme-Context:

// 1. Type Definition
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// 2. Context Creation
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// 3. Provider Implementation
function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => {
    // Initialer Wert aus localStorage
    const saved = localStorage.getItem('theme');
    return (saved === 'dark' || saved === 'light') ? saved : 'light';
  });
  
  const toggleTheme = useCallback(() => {
    setTheme(prev => {
      const next = prev === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', next);
      return next;
    });
  }, []);
  
  const value = useMemo(() => ({
    theme,
    toggleTheme
  }), [theme, toggleTheme]);
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// 4. Custom Hook
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme muss innerhalb von ThemeProvider verwendet werden');
  }
  return context;
}

// 5. Consumer Components
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙' : '☀️'}
    </button>
  );
}

function ThemedContent() {
  const { theme } = useTheme();
  
  return (
    <div className={`content theme-${theme}`}>
      Content mit Theme-Styling
    </div>
  );
}

// 6. App Setup
function App() {
  return (
    <ThemeProvider>
      <header>
        <ThemeToggle />
      </header>
      <main>
        <ThemedContent />
      </main>
    </ThemeProvider>
  );
}

Dieser Context zeigt alle Best Practices: Saubere Type-Definition, memoizierter Provider-Wert, Custom Hook mit Null-Check, Persistence mit localStorage. Consumer sind einfach und fokussiert.

Context ist React’s Antwort auf globale Datenverteilung. Richtig eingesetzt – für wirklich globale, relativ statische Daten – vereinfacht es Architekturen erheblich. Falsch eingesetzt – für lokale Concerns oder hochfrequente Updates – schafft es mehr Probleme als es löst. Die Kunst liegt darin, zu erkennen, wann Props ausreichen und wann Context die bessere Wahl ist.