Gemeinsamer Zustand: nach oben
Kurz gesagt: Beim Hochziehen des Zustands (lifting state up) wandert der gemeinsame Zustand zum nächsten gemeinsamen Elternelement: Daten kommen als Props nach unten, und Änderungen gehen als Ereignis (emit) nach oben.
Problem
Der Zähler oben weiß von nichts
Der Shop hat oben einen Warenkorb-Zähler und darunter eine Produktliste. Lege Brot, Milch und noch ein Brot hinein.
Konsole
Die Brotkarte sagt „Im Warenkorb: 2“, und oben steht „Warenkorb (0)“. Der Warenkorb lebt in ProductList: Dort wird der Button gedrückt, und dort wurde der Zustand angelegt. CartBadge ist eine andere Komponente, ein Geschwister von ProductList, und hat eine eigene Zahl, die niemand ändert. Die beiden Komponenten haben zwei Warenkörbe, und jede hat mit ihrem eigenen recht.
Die Idee
Hinauf zum gemeinsamen Elternelement
Eine Komponente sieht nur ihren eigenen Zustand und die Props, die ihr Elternelement ihr gibt. Ein Geschwister sieht sein Geschwister nicht. Zwei Stellen, die einen Warenkorb zeigen, können also nicht jeweils ihren eigenen haben.
Die Lösung: Der Zustand zieht zum nächsten gemeinsamen Elternelement der beiden. Hier ist das App. Das heißt Zustand nach oben holen (lifting state up):
Apphältitemsund berechnetcount.- Daten kommen als Props nach unten:
countzuCartBadge,itemszuProductList. - Änderungen gehen als Nachricht nach oben: Der Button in
ProductCardändert den Warenkorb nicht, er sagtApp: „Füge dieses Produkt hinzu“.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));function add(product) { /* ändert items */ }</script>
<template> <header><CartBadge :count="count" /></header> <main><ProductList :products="PRODUCTS" :items="items" @add="add" /></main></template>Das Kind deklariert mit defineEmits(['add']), welche Ereignisse es sendet, und sendet sie mit emit('add', product). Das Elternelement hört mit @add="add" zu, genauso wie es bei einem Button @click abhört.
Jetzt haben CartBadge und ProductList keinen eigenen Zustand für den Warenkorb mehr. Sie zeigen, was App ihnen gibt.
Sieh es dir an
Sieh zu, wie er nach oben wandert
Zuerst liegt der Warenkorb in ProductList. Dann wandert er nach App und fließt von dort nach unten.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0),);function add(product) { plusOne(items.value, product);}</script><template> <CartBadge :count="count" /> <ProductList :items="items" @add="add" /></template>
<!-- ProductList.vue --><ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" :qty="qtyOf(items, p)" @add="(product) => emit('add', product)" />
<!-- ProductCard.vue --><button @click="emit('add', product)">…</button>Schritt für Schritt, in Worten
- CartBadge und ProductList sind Geschwister: Beide sind Kinder von App. Zuerst liegt der Warenkorb in ProductList, und CartBadge hat eine eigene Zahl.
- Ein Klick auf „Hinzufügen“ erreicht ProductList, und die Liste zählt 1. CartBadge sieht diesen Zustand nicht: Ein Geschwister kann den Zustand seines Geschwisters nicht lesen. Es bleibt bei 0.
- Die Lösung: Zieh den Warenkorb zum nächsten gemeinsamen Elternelement der beiden, App. Der Zähler gibt seine eigene Kopie auf.
- Von App fließen die Daten als Props nach unten: count zu CartBadge, items zu ProductList und qty zu jeder ProductCard. Beide Geschwister lesen jetzt einen Warenkorb.
- Das Kind besitzt den Warenkorb nicht, also kann es ihn nicht ändern. Der Klick sendet das Ereignis add mit dem Produkt. ProductList reicht es weiter, und App hört mit @add zu und ruft add() auf.
- App ändert items, und die neuen Props fließen nach unten: Der Zähler sagt 2, die Karte sagt 2. Niemand hat sie von Hand aktualisiert.
- Die Regel: Der Zustand lebt an einer Stelle, so hoch wie nötig und nicht höher. Daten gehen als Props nach unten, Änderungen gehen als Ereignisse nach oben.
Die Richtung ist in jeder App dieselbe: Daten nach unten, Nachrichten nach oben. Wem die Daten gehören, der allein ändert sie.
Probier es aus
Hol den Warenkorb nach oben
Der Warenkorb liegt in ProductList, und CartBadge zählt eine eigene Zahl. Zieh den Warenkorb nach App: CartBadge bekommt die Anzahl als Prop, ProductList bekommt items und meldet „Hinzufügen“ nach oben. Die Prüfung legt Brot, Milch und Brot hinein und schaut auf die Karten und den Zähler.
Konsole
Lösung
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.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 },
];
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 });
}
</script>
<template>
<header>
<a href="/">Der Shop</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Produkte</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Warenkorb ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</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="emit('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; }
items, add und count ziehen nach App.vue. CartBadge bekommt :count="count". ProductList bekommt :items="items" und sendet add nach oben: defineEmits(['add']) und @add="(product) => emit('add', product)" an jeder ProductCard. In ProductList gibt es keine ref mehr.
Schreibt App nicht @add="add", wird das Ereignis gesendet, aber niemand hört zu. Es passiert nichts, und in der Konsole steht kein Fehler. Die Prüfung fängt es am Verhalten ab.
Die Idee
Das Kind meldet, das Elternelement entscheidet
ProductCard weiß nicht, wie der Warenkorb gebaut ist. Sie weiß nur, dass jemand bei ihrem Produkt auf „Hinzufügen“ gedrückt hat. Also meldet sie, was passiert ist, nicht, was zu ändern ist: „füge dieses Produkt hinzu“, nicht „erhöhe qty in Zeile 2“. App entscheidet, was das bedeutet: eine neue Zeile oder eine Einheit mehr.
Das hält die Karte einfacher, und sie kann überall stehen: im Katalog, bei „Empfohlen“, in der Suche. Sie besitzt die Daten nicht und fasst sie nicht an.
Das Ereignis ist ein Verb ohne on: add, remove, change. Das Kind deklariert es in defineEmits(['add']), sendet es mit emit('add', product), und das Elternelement hört mit @add zu. Das zweite Argument von emit ist das, was die Funktion des Elternelements bekommt.
Probier es aus
Fülle die Lücke
Was gehört in die Lücke, damit der Klick das Elternelement erreicht?
<script setup>
defineProps(['product']);
const emit = defineEmits(['add']);
</script>
<template>
<button @click="('add', product)">Hinzufügen</button>
</template>emit: die Funktion aus defineEmits. Sie sendet das Ereignis add mit dem Produkt, und das Elternelement hört mit @add zu.Probier es aus
Finde den Fehler
Wähle die Zeile mit dem Fehler.
App hört auf @added, und die Karte sendet add. Die Namen müssen übereinstimmen: @add="add".Die Idee
Wenn der Baum tief wird
Wie weit nach oben? So hoch wie nötig und nicht höher. Wenn nur ProductCard etwas benutzt (ob die Beschreibung geöffnet ist), bleibt es dort. Du ziehst es nach oben, wenn eine zweite Komponente es sehen muss.
In einem echten Shop braucht man den Warenkorb an vielen Stellen: den Zähler oben, die Liste, die Produktseite, die Bestellung. Das gemeinsame Elternelement liegt weit oben, und der Weg zu ihnen führt durch Komponenten, die den Warenkorb nicht benutzen, sondern ihn nur nach unten reichen. Das heißt Prop Drilling. Bei zwei Ebenen ist es normal. Bei fünf Ebenen muss jede Komponente auf dem Weg von Daten wissen, die ihr nicht gehören.
Dafür gibt es eigene Werkzeuge: provide/inject und Pinia. Sie geben die Daten an weit entfernte Nachfahren weiter, ohne die Komponenten dazwischen. Sie stehen in der Lektion über gemeinsamen Zustand im Modul „Effekte, Daten und Formulare“. Die Regel bleibt gleich: Die Daten haben einen Besitzer, und Änderungen laufen über ihn.
Genauer, für Neugierige
Props sind schreibgeschützt. props.qty = 3 ändert in Vue nichts und schreibt [Vue warn] Set operation on key "qty" failed: target is readonly in die Konsole. Ein Kind, das etwas ändern will, meldet nach oben.
v-model auf einer Komponente ist dasselbe. <QtyInput v-model="qty" /> ist die Kurzform von :modelValue="qty" und @update:modelValue="(v) => qty = v". Die Daten kommen weiterhin als Prop nach unten und gehen weiterhin als Ereignis nach oben.
Wozu defineEmits. Deklarierte Ereignisse sind Dokumentation: Du siehst, was die Komponente sagt. Und Vue prüft sie: Sendet die Karte emit('added') und hat nur add deklariert, meldet die Konsole Component emitted event "added" but it is neither declared in the emits option….
ref(props.items) im Kind kopiert das Array nicht: Es ist dasselbe reaktive Array des Elternelements, und alles, was hinzukommt, erscheint. Aber items.value = items.value.filter(…) ändert nur die Box des Kindes. Das Entfernen erreicht das Elternelement nie, und die beiden Warenkörbe laufen auseinander.Übung
Der Warenkorb an der Seite
Neben der Liste gibt es einen dritten Teil, CartSummary: die Überschrift „Dein Warenkorb“, Zeilen wie „Weißbrot × 2“ mit einem Button „Entfernen“, „Gesamt: 4,00 €“ und „Der Warenkorb ist leer“, wenn er leer ist. Im Moment hält CartSummary einen eigenen Warenkorb, den niemand füllt. Gib ihm den Warenkorb aus App und sorge dafür, dass „Entfernen“ daraus entfernt. Die Prüfung legt drei Produkte hinein, entfernt sie und schaut auf den Zähler, die Karten und die Summe.
Konsole
Lösung
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.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 },
];
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 });
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<header>
<a href="/">Der Shop</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Produkte</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
<CartSummary :items="items" @remove="remove" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Warenkorb ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</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="emit('add', product)">
Hinzufügen
</button>
</li>
</template>
CartSummary.vue<script setup>
import { computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['items']);
const emit = defineEmits(['remove']);
const total = computed(() => props.items.reduce((sum, i) => sum + i.price * i.qty, 0));
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">Dein Warenkorb</h2>
<p v-if="items.length === 0">Der Warenkorb ist leer</p>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Entfernen ${item.name}`" @click="emit('remove', item)">
Entfernen
</button>
</li>
</ul>
<p>Gesamt: {{ money(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; }
App.vue bekommt eine Funktion remove(item), die items filtert. CartSummary bekommt :items="items", deklariert defineEmits(['remove']), und der Button ruft emit('remove', item) auf. App hört mit @remove="remove" zu. Jetzt lesen der Zähler, die Karten und der Warenkorb an der Seite einen Zustand, also zeigt sich das Entfernen an allen drei Stellen.
ref(props.items) in CartSummary mit Entfernen an Ort und Stelle ab: Die hinzugefügten Produkte erscheinen, weil das Array dasselbe ist, aber items.value = …filter(…) ändert nur die Box von CartSummary. Der Zähler oben bleibt bei 3.Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Bedingungen: leer, Fehler, lädt · Zustand · Objekte und Arrays aktualisieren · Abgeleiteter Zustand
- Geht weiter in: Gemeinsamer Zustand · Routing · Geschwindigkeit: wann eine Komponente neu rendert · Komponenten testen
- Aufgaben zum Thema: Zustand