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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.