Gemeinsamer Zustand
Kurz gesagt: Gemeinsamer Zustand kommt in React von einem Provider weit oben im Baum mit Context und useReducer, und die Komponenten lesen ihn direkt, ohne Props durch jede Ebene.
Problem
Der Zähler zeigt wieder 0
Der Warenkorb ist nach App hochgereicht, wie in der Lektion über das Hochreichen des Zustands. Von dort geht er als Props nach unten: zu Header und von dort zu CartBadge, zu Catalog, von dort zu ProductList und von dort zu jeder ProductCard. Jemand hat Header umgeschrieben, und der Zähler ist stehen geblieben.
Konsole
Leg Weißbrot, Frischmilch und noch einmal Weißbrot hinein. Die Karten zählen, und im Kopfbereich steht „Warenkorb (0)“. App gibt count an Header, aber das neue Header gibt es nicht an CartBadge weiter. Header nutzt den Warenkorb nicht. Es sollte ihn nur weiterreichen, und genau das hat es vergessen.
Die Idee
Ein Provider oben, Konsumenten unten
Daten durch Komponenten zu reichen, die sie nicht nutzen, heißt Prop Drilling. Bei zwei Ebenen ist das normal. Bei fünf hängt jede Komponente auf dem Weg vom Warenkorb ab, und eine vergessene Prop reißt die Kette.
Beide Bibliotheken haben einen Weg, Daten an entfernte Nachkommen zu geben, ohne die Komponenten dazwischen:
- Der Provider ist die Komponente, der die Daten gehören. Er sagt: „Alles unter mir darf das lesen.“
- Der Konsument ist die Komponente, die sie nutzt. Sie nimmt sie direkt, egal wie viele Ebenen dazwischen liegen.
In React heißt das Context:
export const CartContext = createContext(null);
// App.jsx<CartContext value={{ items, count, add }}> <Header /> <main><Catalog /></main></CartContext>
// CartBadge.jsxconst { count } = useContext(CartContext);createContext(null) erzeugt den „Kanal“. null ist der Wert, wenn über der Komponente kein Provider steht. <CartContext value={…}> ist der Provider. useContext(CartContext) liest den Wert des nächsten Providers im Baum nach oben. Ändert sich der Wert, zeichnet sich jede Komponente neu, die ihn liest.
Die Daten haben weiterhin einen Besitzer, und die Änderungen laufen weiter über ihn: add ist die Funktion des Providers. Nur der Weg hat sich geändert.
Sieh es dir an
Sieh dir die beiden Wege an
Zuerst geht der Warenkorb Ebene für Ebene nach unten, und die Kette reißt in der Mitte. Dann wird App zum Provider, und beide Konsumenten lesen direkt.
const CartContext = createContext(null);
function App() { const [items, setItems] = useState([]); const count = items.reduce((s, i) => s + i.qty, 0); function add(product) { /* … */ } return ( <CartContext value={{ items, count, add }}> <Header /> <Catalog /> </CartContext> );}
function Header() { return <header><CartBadge /></header>;}
function CartBadge() { const { count } = useContext(CartContext); return <a href="/cart">Warenkorb ({count})</a>;}
function ProductCard({ product }) { const { add } = useContext(CartContext); return <button onClick={() => add(product)}>Hinzufügen</button>;}Schritt für Schritt, in Worten
- Der Warenkorb lebt in App. Zwei Komponenten weit unten brauchen ihn: CartBadge im Kopfbereich und ProductCard im Katalog.
- Nur mit Props wandert der Warenkorb Ebene für Ebene nach unten. Header, Catalog und ProductList nutzen ihn nicht. Sie reichen ihn nur weiter.
- Jemand schreibt Header um und vergisst count. Die Kette reißt in der Mitte, und der Zähler zeigt 0. Das ist Prop Drilling.
- Stattdessen: App umschließt den Baum mit <CartContext value={…}>. Alles darunter kann den Warenkorb lesen.
- CartBadge und ProductCard lesen ihn mit useContext(CartContext). Die Komponenten dazwischen wissen nichts vom Warenkorb.
- Ein Klick auf „Hinzufügen“ ruft add(product) auf. Die Funktion kommt vom Provider, deshalb passiert die Änderung in App.
- Der Warenkorb in App ändert sich, und jede Komponente, die ihn liest, wird neu gezeichnet: Der Zähler zeigt 1, die Karte zeigt 1.
- Die Daten haben weiterhin einen Besitzer, App. Nur der Weg hat sich geändert: nicht durch jede Ebene, sondern direkt vom Provider.
Die Komponenten in der Mitte wissen jetzt nichts mehr vom Warenkorb. Das neue Header kannst du so oft umschreiben, wie du willst.
Probier es aus
Entferne das Drilling
Der Warenkorb geht als Props durch Header und Catalog nach unten, und Header lässt count fallen. Gib den Warenkorb von oben. Header, Catalog und ProductList bekommen nichts über den Warenkorb, und CartBadge und ProductCard lesen ihn direkt. Die Prüfung legt Weißbrot, Frischmilch und noch einmal Weißbrot hinein und schaut sich die Karten und den Zähler an.
Konsole
Lösung
App.jsximport { useState } from 'react';
import { CartContext } from './cart.js';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
export default function App() {
const [items, setItems] = useState([]);
const count = items.reduce((sum, i) => sum + i.qty, 0);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { ...product, qty: 1 }]);
}
return (
<CartContext value={{ items, count, add }}>
<Header />
<main>
<Catalog />
</main>
</CartContext>
);
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">Der Shop</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Warenkorb ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Produkte</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Weißbrot', price: 1.2 },
{ id: 5, name: 'Frischmilch', price: 1.6 },
{ id: 7, name: 'Weißkäse', price: 7.9 },
];
export default function ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>Im Warenkorb: {qty}</span>}
<button type="button" aria-label={`Hinzufügen ${product.name}`} onClick={() => add(product)}>
Hinzufügen
</button>
</li>
);
}
cart.jsimport { createContext } from 'react';
export const CartContext = createContext(null);
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
CartContext steht schon in cart.js. Umschließe in App Header und main mit <CartContext value={{ items, count, add }}>. CartBadge liest const { count } = useContext(CartContext), ProductCard liest items und add und berechnet sein qty selbst. Entferne die Props für den Warenkorb aus Catalog und ProductList.
Die Prüfung erkennt einen Provider, der nur main umschließt: Header bleibt außerhalb, useContext liefert null, und CartBadge stürzt ab. Sie erkennt auch einen Wert ohne count.
Die Prüfung sieht nur das Verhalten. Reichst du count noch einmal durch Header, besteht sie trotzdem. Das Ziel hier ist ein anderes: Die Mitte soll nichts vom Warenkorb wissen.
Die Idee
Ein Store für den Warenkorb
Der Warenkorb wächst: hinzufügen, entfernen, die Anzahl ändern, leeren. Ist jede Aktion eine Funktion in App, füllt sich App mit Logik, die nicht seine Aufgabe ist. Deshalb zieht der gemeinsame Zustand in eine eigene Datei um, einen Store: die Daten und alles, was sie ändert, an einem Ort.
React hat dafür den eingebauten Hook useReducer. Statt setItems(…) schicken die Komponenten eine Aktion (dispatch({ type: 'add', product })), und eine einzige Funktion, der Reducer, entscheidet, was mit dem Warenkorb passiert:
function cartReducer(items, action) { switch (action.type) { case 'add': /* ein neues Array mit dem Produkt */ default: return items; }}
export function CartProvider({ children }) { const [items, dispatch] = useReducer(cartReducer, []); const add = (product) => dispatch({ type: 'add', product }); return <CartContext value={{ items, count, add }}>{children}</CartContext>;}CartProvider ist der Provider, und App setzt ihn nur um den Baum. Die Konsumenten ändern sich nicht: Sie lesen weiter useContext(CartContext). Der Reducer ist eine gewöhnliche Funktion: Der alte Warenkorb und eine Aktion gehen hinein, ein neuer Warenkorb kommt heraus. Sie berührt nichts anderes, deshalb lässt sie sich leicht testen.
Context und useReducer sind in React eingebaut. Es gibt auch Bibliotheken für Stores, zum Beispiel Zustand und Redux Toolkit. Sie sind nicht in der Sandbox, und hier erwähnen wir sie nur: Die Idee ist dieselbe, eine Quelle und Aktionen, die sie ändern.
Konsole
cart.jsx: Dort steht die ganze Logik des Warenkorbs. CartBadge und ProductCard sind dieselben wie in der Aufgabe.Probier es aus
Finde den Fehler
TypeError: Cannot destructure property 'count' of … as it is null. Welche Zeile ist schuld?Wähle die Zeile mit dem Fehler.
<Header /> steht außerhalb von <CartContext value={…}>. CartBadge darin sucht im Baum nach oben einen Provider und findet keinen, also liefert useContext das null aus createContext(null). Der Provider muss auch Header umschließen.Die Idee
Wann geteilt und wann nicht
Gemeinsamer Zustand ist praktisch, und deshalb ist es verlockend, alles dorthin zu legen. Aber alles darin kann von überall gelesen und geändert werden, und das macht Fehler schwerer auffindbar. Die Regel aus der Lektion über das Hochreichen gilt: so hoch wie nötig, und nicht höher.
- Geteilt: was entfernte Teile des Baums brauchen und was lebt, solange der Nutzer auf der Seite ist. Der Warenkorb, der angemeldete Nutzer, die Sprache, das Design.
- In der Komponente: die Felder eines Formulars, ob das Menü offen ist, welcher Tab gewählt ist, der Text im Suchfeld. Eine Komponente (oder ihr Elternteil) braucht sie, und sie verschwinden mit ihr.
- Die Daten des Servers sind kein gemeinsamer Zustand. Die Produktliste und die Bestellungen haben einen Besitzer, und der Besitzer ist der Server. In einem Store sind sie eine Kopie, die aufgefrischt werden muss. Die Komponente, die sie zeigt, lädt sie, wie in der Lektion über Daten aus einer API.
Gemeinsamer Zustand ändert die Richtung nicht: Die Daten gehen von einem Besitzer aus, und die Änderungen laufen über seine Funktionen.
Probier es aus
Ergänze die Lücke
CartBadge die Anzahl vom Provider?import { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = (CartContext);
return <a href="/cart">Warenkorb ({count})</a>;
}useContext: Es liest den Wert des nächsten <CartContext value={…}> über der Komponente und zeichnet sie neu, wenn sich dieser Wert ändert.Genauer, für Neugierige
<CartContext.Provider>. Vor React 19 wurde der Provider als <CartContext.Provider value={…}> geschrieben. Diese Form funktioniert weiterhin, und <CartContext value={…}> ist die neue, kürzere.
Dein eigener Hook. Neben einem Context steht oft eine kleine Funktion export function useCart() { return useContext(CartContext); }. Sie kann auch einen klaren Fehler auslösen, wenn es keinen Provider gibt, statt eines TypeError über null.
Wer zeichnet neu. Jede Komponente, die den Context liest, zeichnet sich neu, wenn sich sein Wert ändert. value={{ items, count, add }} ist bei jedem Zeichnen des Providers ein neues Objekt. In einer großen Anwendung sichert man das mit useMemo oder mit mehreren kleineren Contexts ab. In der Lektion über Geschwindigkeit siehst du, wie man das misst.
Klassenkomponenten lesen einen Context mit static contextType oder mit <CartContext.Consumer>. Du begegnest ihnen in altem Code.
Der Store lebt, solange die Seite offen ist. Beim Neuladen ist er weg. Ein Warenkorb, der überleben soll, wird in localStorage oder auf dem Server gespeichert.
Die Prüfungen sehen das Verhalten. Die Aufgaben hier sehen nicht, ob der Warenkorb aus Props, aus einem Context oder aus einem Store kommt. Sie prüfen, dass der Zähler und die Karten dasselbe zeigen.
Übung
Die Warenkorbseite
Im Hauptteil steht CartSummary: eine Überschrift Der Warenkorb, Zeilen Weißbrot × 2 mit einem Button Entfernen, Gesamt: 4,00 €, und Der Warenkorb ist leer, wenn er leer ist. Jetzt hat es einen eigenen Warenkorb, den niemand füllt. Füge dem Store das Entfernen und eine Summe hinzu und lass CartSummary den Warenkorb von dort lesen. Die Prüfung legt drei Produkte hinein, entfernt sie und schaut sich den Zähler, die Karten und die Summe an.
Konsole
Lösung
App.jsximport { CartProvider } from './cart.jsx';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import CartSummary from './CartSummary.jsx';
export default function App() {
return (
<CartProvider>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</CartProvider>
);
}
cart.jsximport { createContext, useReducer } from 'react';
export const CartContext = createContext(null);
function cartReducer(items, action) {
switch (action.type) {
case 'add': {
const found = items.find((i) => i.id === action.product.id);
if (found) return items.map((i) => (i.id === found.id ? { ...i, qty: i.qty + 1 } : i));
return [...items, { ...action.product, qty: 1 }];
}
case 'remove':
return items.filter((i) => i.id !== action.id);
default:
return items;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const add = (product) => dispatch({ type: 'add', product });
const remove = (id) => dispatch({ type: 'remove', id });
return <CartContext value={{ items, count, total, add, remove }}>{children}</CartContext>;
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">Der Shop</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Warenkorb ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Produkte</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Weißbrot', price: 1.2 },
{ id: 5, name: 'Frischmilch', price: 1.6 },
{ id: 7, name: 'Weißkäse', price: 7.9 },
];
export default function ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>Im Warenkorb: {qty}</span>}
<button type="button" aria-label={`Hinzufügen ${product.name}`} onClick={() => add(product)}>
Hinzufügen
</button>
</li>
);
}
CartSummary.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function CartSummary() {
const { items, total, remove } = useContext(CartContext);
return (
<section aria-labelledby="cart-title">
<h2 id="cart-title">Der Warenkorb</h2>
{items.length === 0 && <p>Der Warenkorb ist leer</p>}
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Entfernen ${item.name}`} onClick={() => remove(item.id)}>
Entfernen
</button>
</li>
))}
</ul>
<p>Gesamt: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
Füge in cart.jsx eine Aktion case 'remove': return items.filter((i) => i.id !== action.id);, eine Funktion remove = (id) => dispatch({ type: 'remove', id }) und total hinzu und lege sie in value. CartSummary liest const { items, total, remove } = useContext(CartContext) und ruft remove(item.id) auf.
Die Prüfung erkennt einen Reducer, der mit splice entfernt und dasselbe Array zurückgibt: React sieht keine Änderung, und nichts wird gezeichnet. Sie erkennt auch ein CartSummary, das außerhalb von CartProvider steht.
Die Prüfung erkennt auch eine Kopie des Warenkorbs in CartSummary: Sie ist bei der Erzeugung leer und folgt dem Store nicht.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Abgeleiteter Zustand · Gemeinsamer Zustand: nach oben · Daten von einer API in einer Komponente · Formulare in einer Komponente
- Geht weiter in: Routing · Von der Sandbox zum Projekt · Das Schaufenster des Shops
- Aufgaben zum Thema: Effekte, Daten und Formulare