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 gehört. Wer beides verwechselt, baut einen globalen Store durch die Hintertür ein und erzeugt bei unbedacht wechselnden Provider-Werten unnötige Renderläufe.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [filter, setFilter] = useState("");
const [items, setItems] = useState([]);
return (
<AppContext.Provider value={{ user, setUser, filter, setFilter, items, setItems }}>
{children}
</AppContext.Provider>
);
}
Drei Zustände hängen an einem einzigen Provider, und das Objekt hinter value entsteht bei jedem Rendern neu. Ein Zeichen im Filterfeld genügt, und alles, was diesen Context liest, rendert mit – auch der Header, der nur den Namen der angemeldeten Person anzeigt. Aus einer Abkürzung durch den Komponentenbaum ist ein Store geworden, ohne dass jemand diese Entscheidung bewusst getroffen hätte.
Context kann genau eine Sache: einen Wert oben bereitstellen und weiter unten lesbar machen, ohne dass die Schichten dazwischen ihn durchreichen. Alles andere – wo der Wert entsteht, wer ihn fortschreibt, wie oft er wechselt – bleibt Handarbeit und muss vorher entschieden sein. 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 liegt – 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. React vergleicht den alten und neuen Provider-Wert mit Object.is. Ist er verschieden, werden die Komponenten neu gerendert, die diesen Context lesen; bloße Nachfahren ohne useContext sind damit nicht automatisch gemeint. Bei einem großen Objekt als Context betrifft eine geänderte Referenz allerdings auch Consumer, die nur ein anderes Feld daraus verwenden. Aus einem kleinen Update kann so eine Renderwelle werden.
flowchart TB Change["Context-Wert ändert sich"] --> P["Provider"] P --> C1["Consumer A<br/>zeigt den Wert"] P --> C2["Consumer B<br/>liest ein anderes Feld –<br/>rendert trotzdem"] P --> C3["Consumer C<br/>liest ein anderes Feld –<br/>rendert trotzdem"]
In einem Projekt lag neben dem Theme beiläufig auch der Wert eines Suchfelds im selben Context. 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 liegt 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. Vor jedem neuen Context lohnt darum eine Prüffrage: Beschreibt der Wert die Umgebung, in der eine Komponente läuft, oder die Daten, die sie anzeigt? Nur das Erste gehört hinein.
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
- react.dev:
useContext, Wertvergleich und Re-Rendering
Wie fandest du diesen Beitrag?
Kommentare