Context richtig einsetzen: Verteilweg statt heimlicher globaler Zustand
Context löst das Verteilen von Daten durch den Baum – aber nicht die Frage, wem der Zustand eigentlich gehört. Wer beides verwechselt, baut sich einen globalen Store durch die Hintertür ein, samt Rendern jedes Consumers bei jeder Änderung.
Context richtig einsetzen: Verteilweg statt heimlicher globaler Zustand
Im React-Überblick habe ich Context in einem einzigen Satz abgehandelt und versprochen, ihm einen eigenen, genaueren Beitrag zu widmen. Der Grund für diese Zurückhaltung ist kein Platzmangel gewesen, sondern ein Missverständnis, das ich in Schulungen fast jedes Mal ausräumen muss: Context wird als Zustandsverwaltung verkauft, obwohl er das nicht ist. Er löst genau ein Problem – das Verteilen von Daten durch den Komponentenbaum. Er löst ausdrücklich nicht die Frage, wem diese Daten gehören und wo sie leben. Wer diese beiden Fragen zusammenwirft, holt sich einen globalen Zustand ins Haus, ohne es zu merken.
Das klingt nach Wortklauberei, ist aber der Unterschied zwischen einer Anwendung, die man versteht, und einer, in der jede Änderung an einer zentralen Stelle unerwartete Neurenderings quer durch die Oberfläche auslöst. Fangen wir bei dem Problem an, für das Context gebaut wurde.
Das Problem: prop drilling
Daten fließen in React von oben nach unten. Eine Komponente reicht Werte über Props an ihre Kinder, die reichen sie an ihre Kinder weiter, und so fort. Solange die Strecke kurz ist, ist das genau richtig – der Datenfluss ist sichtbar, man liest an den Props ab, was eine Komponente braucht.
Unangenehm wird es, wenn ein Wert weit oben entsteht und weit unten gebraucht wird, aber alles dazwischen ihn gar nicht braucht. Die angemeldete Person zum Beispiel: Sie wird einmal ganz oben ermittelt, gebraucht wird sie im Header, in einem Kommentarformular tief im Baum und in einem Menü ganz woanders. Alle Komponenten auf dem Weg dorthin müssen den Wert entgegennehmen und weiterreichen, obwohl sie ihn nur durchschleusen.
function Page({ user }) {
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <Profile user={user} />;
}
function Profile({ user }) {
return <span>{user.name}</span>;
}
Layout und Sidebar interessieren sich nicht für user. Sie tragen die Prop nur weiter, weil weiter unten jemand sie braucht. Genau das nennt man prop drilling – man bohrt einen Wert durch eine Schicht Komponenten, die mit ihm nichts zu tun haben. Es macht Zwischenkomponenten schwerer wiederverwendbar, weil sie an Daten gekoppelt sind, die sie nur durchreichen, und jede neue Zwischenschicht muss die Prop von Hand mitnehmen.
flowchart TB
subgraph drilling["prop drilling"]
P1["Page (user)"] --> L1["Layout<br/>reicht user nur weiter"]
L1 --> S1["Sidebar<br/>reicht user nur weiter"]
S1 --> Pr1["Profile<br/>braucht user"]
end
subgraph context["Context"]
P2["Provider (user)"] -.-> Pr2["Profile<br/>liest per useContext"]
end
Wie Context funktioniert
Context dreht dieses Durchreichen um. Statt einen Wert Schicht für Schicht weiterzugeben, stellt man ihn oben einmal bereit und liest ihn unten direkt aus – ohne die Zwischenschichten zu belästigen. Drei Teile gehören dazu: createContext erzeugt das Objekt, ein Provider stellt den Wert für einen Teilbaum bereit, und useContext liest ihn dort aus, wo er gebraucht wird.
import { createContext, useContext } from "react";
const CurrentUserContext = createContext(null);
function Page({ user }) {
return (
<CurrentUserContext.Provider value={user}>
<Layout />
</CurrentUserContext.Provider>
);
}
function Profile() {
const user = useContext(CurrentUserContext);
return <span>{user.name}</span>;
}
Layout und Sidebar sind aus dem Spiel – sie kennen user nicht mehr und müssen nichts weiterreichen. Profile greift sich den Wert direkt beim nächstgelegenen Provider über sich. Genau das ist die Aufgabe von Context, und für diese Aufgabe ist er das richtige Werkzeug: Er ist ein Verteilweg, eine Abkürzung durch den Baum.
Wichtig ist, was hier nicht passiert. Der Provider erzeugt den Wert nicht. Er bekommt ihn von oben hereingereicht und stellt ihn zur Verfügung. Wo user herkommt, wer ihn ändert, wo er lebt – all das ist an dieser Stelle noch völlig offen. Und genau hier fangen die Probleme an, wenn man nicht aufpasst.
Die zentrale Falle: Context als Ersatz für Zustandsbesitz
Weil Context Werte so bequem überall verfügbar macht, liegt eine Versuchung nahe: Man legt einfach alles hinein, was mehrere Komponenten brauchen. Die geladene Liste, den aktuellen Filter, halb geöffnete Dialoge, den gesamten Anwendungszustand – Context wird zum Ablageort für alles Geteilte. Das fühlt sich anfangs großartig an und rächt sich später zuverlässig.
Zwei Konsequenzen fallen einem auf die Füße. Die erste ist das Rendern. Jede Komponente, die einen Context per useContext liest, wird neu gerendert, sobald sich der Context-Wert ändert – ausnahmslos, quer durch den ganzen Teilbaum unter dem Provider. Legt man häufig wechselnde Daten hinein, rendert bei jeder Änderung alles neu, was diesen Context liest, auch die Teile, die den geänderten Wert gar nicht anzeigen. Aus einem kleinen Update wird eine Renderwelle.
flowchart TB Change["Context-Wert ändert sich"] --> P["Provider"] P --> C1["Consumer A<br/>zeigt den Wert"] P --> C2["Consumer B<br/>zeigt ihn nicht –<br/>rendert trotzdem"] P --> C3["Consumer C<br/>zeigt ihn nicht –<br/>rendert trotzdem"]
Ich habe das einmal in einer Schulung an einem Context erlebt, in dem neben dem Theme beiläufig auch ein Suchfeld-Wert lag. Bei jedem Tastendruck im Suchfeld flackerte die halbe Oberfläche – Komponenten, die mit der Suche nichts zu tun hatten, renderten mit, nur weil sie am selben Context hingen. Der Wert war im falschen Behälter gelandet.
Die zweite Konsequenz ist die Kopplung. Alles, was aus einem Context liest, ist an diesen Provider gebunden. Man kann eine solche Komponente nicht mehr isoliert verwenden, testen oder an anderer Stelle einsetzen, ohne den passenden Provider drumherum aufzubauen. Je mehr in einem Context steckt, desto mehr Komponenten hängen an ihm, und desto zäher wird die Anwendung. Context macht die Herkunft eines Wertes unsichtbar – das ist beim Verteilen ein Vorteil und beim Besitz ein Problem, weil man nicht mehr sieht, wer eigentlich für einen Wert verantwortlich ist.
Der Kern des Missverständnisses ist dieser: Context beantwortet die Frage „wie kommen die Daten dorthin, wo sie gebraucht werden". Er beantwortet nicht die Frage „wem gehören die Daten und wer darf sie ändern". Die zweite Frage – den Zustandsbesitz – muss man weiterhin selbst beantworten, mit useState oder useReducer in einer klar benannten Komponente. Context ersetzt diese Entscheidung nicht, er transportiert nur ihr Ergebnis.
Regeln für den sauberen Einsatz
Aus dieser Trennung folgen drei Regeln, mit denen Context genau das bleibt, was er sein soll – ein Transportmittel.
Erstens: der Besitz liegt oben, Context reicht nur durch. Der Zustand gehört in eine klar benannte Komponente – nennen wir sie CurrentUserProvider –, die ihn mit useState oder useReducer hält. Diese Komponente ist die eine Stelle, an der der Wert lebt und geändert wird. Context transportiert ihn von dort nach unten, mehr nicht. Wer den Provider aufmacht und useState sucht, findet den Besitzer sofort; wer ihn nicht findet, hat wahrscheinlich einen versteckten globalen Zustand gebaut.
Zweitens: den Wert stabil halten. Hier steckt der häufigste konkrete Fehler. Schreibt man ein frisches Objekt direkt ins value, erzeugt jedes Rendern der Provider-Komponente eine neue Referenz – und weil sich die Referenz ändert, rendern alle Consumer neu, selbst wenn sich inhaltlich nichts geändert hat:
function CurrentUserProvider({ children }) {
const [user, setUser] = useState(null);
// new object on every render → all consumers re-render
return (
<CurrentUserContext.Provider value={{ user, setUser }}>
{children}
</CurrentUserContext.Provider>
);
}
Die Lösung ist, den Wert zu memoisieren, sodass die Referenz nur wechselt, wenn sich tatsächlich etwas geändert hat:
function CurrentUserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<CurrentUserContext.Provider value={value}>
{children}
</CurrentUserContext.Provider>
);
}
Bei Zustand aus useReducer lohnt oft ein zweiter Schritt: State und Dispatch in zwei getrennte Contexts legen. Der Dispatch ist stabil, er ändert sich nie. Trennt man ihn ab, rendern Komponenten, die nur Aktionen auslösen und den State gar nicht anzeigen, bei einer State-Änderung nicht mehr mit – sie hängen nur am unveränderlichen Dispatch-Context.
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
Drittens: nicht jeden veränderlichen Wert hineinlegen. Manches, was man reflexhaft in einen Context steckt, gehört anderswo hin. Serverdaten – geladene Listen, Detaildaten, Suchergebnisse – sind kein Anwendungszustand, sondern ein Cache, und der gehört in eine Data-Fetching-Bibliothek, nicht in Context. Diese Grenze habe ich in Client-Zustand vs. Server-Cache ausführlich behandelt. Und die aktuelle Auswahl, der Filter, die Seite in einer Liste gehören in die URL – teilbar, mit Lesezeichen speicherbar, überlebt einen Reload. Zieht man diese beiden Kategorien ab, bleibt für Context erstaunlich wenig übrig, und genau dieser Rest ist sein eigentliches Zuhause.
Wofür Context wirklich gut ist
Context spielt seine Stärke bei selten wechselnden, app-weiten Dingen aus – bei dem, was eher die Umgebung einer Anwendung beschreibt als ihre eigentlichen Daten. Das aktive Theme, die eingestellte Sprache, die angemeldete Person, eine app-weite Service- oder Dispatch-Funktion: Solche Werte ändern sich selten, werden aber an vielen Stellen gebraucht. Genau dafür ist ein Verteilweg gedacht, der die Zwischenschichten überspringt.
Der gemeinsame Nenner dieser guten Fälle ist, dass sie „Umgebung" sind und nicht „Anwendungsdaten". Sie wechseln nicht im Sekundentakt, also fällt die Renderwelle kaum ins Gewicht. Und sie sind tatsächlich app-weit, also stört die Kopplung an einen Provider nicht – man will diese Komponenten ohnehin nur innerhalb der Anwendung verwenden. Der Merksatz, den ich in Schulungen mitgebe: Context ist für die Umgebung, in der eine Komponente läuft, nicht für die Daten, die sie anzeigt.
Context und Reducer als kleiner globaler Kern
Für einen überschaubaren globalen Kern lassen sich Context und useReducer zu einem leichten Muster kombinieren. Der Reducer hält den Zustand und die Logik an einer Stelle, zwei getrennte Contexts verteilen State und Dispatch, und die Consumer greifen über kleine Hooks zu:
function useStore() {
return useContext(StateContext);
}
function useDispatch() {
return useContext(DispatchContext);
}
Das reicht überraschend weit – für einen Warenkorb, ein paar app-weite Einstellungen, eine kleine Sammlung von Aktionen. Der Besitz ist klar (im StoreProvider), der Transport ist sauber (zwei Contexts), und die Trennung von State und Dispatch hält die Renderings im Zaum.
Man sollte aber die Grenze dieses Musters kennen und offen benennen: Context ist keine Zustandsverwaltung, er ist Transport. Der Reducer daneben ist die eigentliche Logik, und Context reicht nur dessen Ergebnis durch. Solange der Kern klein bleibt, trägt das Muster. Wächst die Logik – viele voneinander abhängige Teilzustände, selektives Lesen einzelner Felder ohne Renderwellen, Middleware, Persistenz, Zeitreise beim Debuggen –, stößt die Kombination an ihre Grenzen. Dann braucht es einen echten Store, der genau für diese Aufgaben gebaut ist. Das ist keine Niederlage von Context, sondern die richtige Arbeitsteilung: Context hat nie behauptet, ein Store zu sein.
Was bleiben soll
Context beantwortet eine einzige Frage sehr gut: wie die Daten dorthin kommen, wo sie gebraucht werden. Er beantwortet die andere, wichtigere Frage nicht: wem sie gehören und wer sie ändert. Diese zweite Frage muss man selbst beantworten, in einer klar benannten Komponente mit useState oder useReducer, bevor man Context überhaupt anfasst.
Wer diese beiden Fragen sauber trennt, benutzt Context als das, was er ist – einen Verteilweg für wenige, selten wechselnde, app-weite Werte, mit stabilem value und, wo nötig, getrennten State- und Dispatch-Contexts. Wer sie vermischt, baut sich einen globalen Zustand durch die Hintertür, samt Renderwellen und Kopplung. Der Unterschied kostet beim Schreiben eine einzige bewusste Entscheidung und erspart hinterher eine Menge Fehlersuche.
Weiterführende Quellen
- Repository introduction-react: https://github.com/mikebild/introduction-react
- react.dev, „Passing Data Deeply with Context": https://react.dev/learn/passing-data-deeply-with-context
- react.dev, „Scaling Up with Reducer and Context": https://react.dev/learn/scaling-up-with-reducer-and-context
Kommentare
Kommentar schreiben