Gemeinsamer Zustand
Kurz gesagt: Gemeinsamer Zustand kommt in Vue von provide/inject oder einem Pinia-Store, 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 Vue sind das provide und inject:
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));provide('cart', { items, count, add });</script>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script>provide('cart', …) gibt einen Wert unter einem Schlüssel an alle Nachkommen der Komponente. inject('cart') nimmt ihn vom nächsten Vorfahren, der ihn bereitgestellt hat. Weitergegeben werden die Refs (items, count), nicht ihre Werte, damit die Konsumenten reaktiv bleiben.
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.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0));function add(product) { /* … */ }provide('cart', { items, count, add });</script><template> <Header /> <Catalog /></template>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script><template> <a href="/cart">Warenkorb ({{ count }})</a></template>
<!-- ProductCard.vue --><script setup>const { add } = inject('cart');</script><template> <button @click="add(product)">Hinzufügen</button></template>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 ruft provide('cart', …) auf. Jeder Nachkomme von App kann den Warenkorb lesen.
- CartBadge und ProductCard lesen ihn mit inject('cart'). 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.vue<script setup>
import { ref, computed, provide } from 'vue';
import Header from './Header.vue';
import Catalog from './Catalog.vue';
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
provide('cart', { items, count, add });
</script>
<template>
<Header />
<main>
<Catalog />
</main>
</template>
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">Der Shop</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { inject } from 'vue';
const { count } = inject('cart');
</script>
<template>
<a href="/cart">Warenkorb ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Produkte</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
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 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed, inject } from 'vue';
import { money } from './money.js';
const props = defineProps(['product']);
const { items, add } = inject('cart');
const qty = computed(() => items.value.find((i) => i.id === props.product.id)?.qty ?? 0);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">Im Warenkorb: {{ qty }}</span>
<button type="button" :aria-label="`Hinzufügen ${product.name}`" @click="add(product)">
Hinzufügen
</button>
</li>
</template>
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; }
Rufe in App.vue provide('cart', { items, count, add }) auf und entferne die Props und @add aus Header und Catalog. CartBadge liest const { count } = inject('cart'). ProductCard liest items und add und berechnet die Anzahl mit computed. Catalog und ProductList haben kein defineProps und defineEmits mehr für den Warenkorb.
Die Prüfung erkennt provide('cart', { count: count.value, … }): Es wird die Zahl in diesem Moment weitergegeben, nicht das Ref, und der Zähler bleibt bei 0. Sie erkennt auch qty ohne computed in ProductCard: setup läuft einmal, also ändert sich die Zahl nicht.
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.
Für Stores nutzt Vue Pinia, die offizielle Bibliothek für gemeinsamen Zustand. Ein Store wird mit defineStore erzeugt und wie <script setup> geschrieben: Ein ref ist der Zustand, ein computed wird daraus abgeleitet, die Funktionen sind die Aktionen.
export const useCartStore = defineStore('cart', () => { const items = ref([]); const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0)); function add(product) { /* … */ } return { items, count, add };});Pinia wird einmal installiert, in main.js: createApp(App).use(createPinia()).mount('#app'). Jede Komponente ruft useCartStore() auf und bekommt denselben Store. Ein provide in App ist nicht nötig.
<script setup>import { storeToRefs } from 'pinia';const cart = useCartStore();const { count } = storeToRefs(cart);</script>Der Store ist ein reactive-Objekt: Im Template schreibst du cart.count, ohne .value. Willst du ihn in Variablen zerlegen, nimm storeToRefs(cart). Das liefert Refs, die mit dem Store verbunden bleiben. Die Aktionen (cart.add) kannst du direkt nehmen.
Konsole
cart.js: Dort steht die ganze Logik des Warenkorbs. App.vue hat keinen Zustand mehr, und main.js installiert Pinia.Probier es aus
Finde den Fehler
Wähle die Zeile mit dem Fehler.
const { count } = useCartStore() nimmt die Zahl in diesem Moment und trennt sie vom Store ab. Es müsste const { count } = storeToRefs(useCartStore()) heißen, oder cart.count im Template.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 count aus dem Store, damit es mit ihm verbunden bleibt?<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = (useCartStore());
</script>storeToRefs: Es liefert ein Ref für jedes Stück Zustand und jedes berechnete Feld des Stores. Ein zerlegtes Ref bleibt verbunden, eine zerlegte Zahl nicht.Genauer, für Neugierige
Ein Store mit Optionen. defineStore nimmt auch ein Objekt: state: () => ({ items: [] }), getters und actions. Das ist dasselbe, geschrieben als Options API. Pinia unterstützt beide Arten.
provide/inject oder Pinia. provide ist eingebaut und eignet sich für Dinge, die ein Teilbaum braucht (die Einstellungen einer Tabelle, ein Formular mit verschachtelten Feldern). Pinia ist für den Zustand der ganzen Anwendung: Es ist in den Vue DevTools sichtbar, lässt sich für sich testen und hängt nicht davon ab, welche Komponente oben steht.
Ein Symbol als Schlüssel. Statt des Strings 'cart' nimmt provide oft ein Symbol(), das aus einer gemeinsamen Datei exportiert wird. Dann können zwei verschiedene Provider nicht aus Versehen denselben Schlüssel haben.
inject mit Standardwert. inject('cart', null) schreibt keine Warnung, wenn niemand den Schlüssel bereitgestellt hat, und liefert null.
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
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
createApp(App).use(createPinia()).mount('#app');
App.vue<script setup>
import Header from './Header.vue';
import Catalog from './Catalog.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</template>
cart.jsimport { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, count, total, add, remove };
});
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">Der Shop</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = storeToRefs(useCartStore());
</script>
<template>
<a href="/cart">Warenkorb ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Produkte</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
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 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed } from 'vue';
import { useCartStore } from './cart.js';
import { money } from './money.js';
const props = defineProps(['product']);
const cart = useCartStore();
const qty = computed(() => cart.items.find((i) => i.id === props.product.id)?.qty ?? 0);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">Im Warenkorb: {{ qty }}</span>
<button type="button" :aria-label="`Hinzufügen ${product.name}`" @click="cart.add(product)">
Hinzufügen
</button>
</li>
</template>
CartSummary.vue<script setup>
import { useCartStore } from './cart.js';
import { money } from './money.js';
const cart = useCartStore();
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">Der Warenkorb</h2>
<p v-if="cart.items.length === 0">Der Warenkorb ist leer</p>
<ul>
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Entfernen ${item.name}`" @click="cart.remove(item.id)">
Entfernen
</button>
</li>
</ul>
<p>Gesamt: {{ money(cart.total) }}</p>
</section>
</template>
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.js total mit computed und eine Aktion remove(id) hinzu, die items.value durch ein gefiltertes Array ersetzt, und gib sie aus dem Store zurück. CartSummary ruft const cart = useCartStore() auf und nutzt im Template cart.items, cart.total und cart.remove(item.id).
Die Prüfung erkennt const { items, total, remove } = useCartStore(): Die Zeilen erscheinen, weil das Array dasselbe ist, aber total ist die Zahl im Moment der Erzeugung, und die Summe bleibt bei 0.
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