Zustand am richtigen Ort
Auftrag
Martina Hostettler hat einen neuen Wunsch.
Als Mitglied möchte ich meine Anfragen überall in der App sehen, damit ich weiss, was ich schon angefragt habe.
Der Freiwillige zögert. App stellt schon Katalog, Favoriten und das angemeldete Mitglied bereit, und jede neue Seite braucht wieder etwas davon. Bevor ein viertes Stück dazukommt, will er für jedes Stück klären, wo es hingehört. Die Akzeptanzkriterien lauten:
- Für Favoriten, Anmeldung, Filter und Anfragen ist entschieden und begründet, wo sie liegen.
- Kopf und Liste zeigen dieselben Favoriten, und keine Komponente hält eine eigene Kopie.
- Kopf und Übersicht zeigen dieselben Anfragen, egal wo jemand ein Ding anfragt.
Lernziele
Nach diesem Kapitel kannst du
- lokalen, geteilten und globalen Zustand unterscheiden,
- Daten vom Server und Zustand in der Adresse erkennen,
- Zustand in die Elternkomponente heben und mit Props und Events weitergeben,
- geteilten Zustand in ein Modul mit Funktionen legen,
- begründen, wann eine App keinen Store braucht.
Arten von Zustand
Abschnitt betitelt „Arten von Zustand“Zustand sind die Daten, die sich ändern, während jemand mit der App arbeitet, und die die Seite zeigt. Die Fallbeispiel-App hat davon schon einiges. Das Suchfeld hält seinen Text, der Dialog weiss, ob er offen ist, App hält Katalog, Favoriten und Mitglied, und Suche, Kategorie und Sortierung stehen seit Teil 6 in der Adresse. Die Orte unterscheiden sich darin, wer den Zustand braucht und wie lange er leben soll.
| Art | Beispiel in der Fallbeispiel-App | Ort |
|---|---|---|
| lokal | ob der Dialog offen ist | ein ref in der Komponente |
| geteilt | die Favoriten für Kopf und Liste | die gemeinsame Elternkomponente |
| global | das angemeldete Mitglied | ein Modul oder ein Store |
| vom Server | Katalog und Anfragen | der Server, im Browser eine Kopie |
| in der Adresse | Suche, Kategorie und Sortierung | Parameter und Query |
Die Abbildung stellt die Fragen in einer Reihenfolge, die zu einer Antwort führt. Zuerst kommt die Adresse, denn was jemand teilen oder neu laden will, muss dort stehen. Dann folgt der Server, denn Daten, die anderen gehören, kann die App nur abfragen. Erst danach geht es darum, wie viele Komponenten einen Wert brauchen.
Zustand in die Elternkomponente heben
Abschnitt betitelt „Zustand in die Elternkomponente heben“Brauchen zwei Geschwister denselben Zustand, gehört er in ihre gemeinsame Elternkomponente. Sie gibt ihn als Prop weiter und ändert ihn, wenn ein Kind ein Event meldet, wie in Teil 4.
App.vue
<script setup>import { ref } from 'vue'
import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'
// Header and list both need the favorites, so they live in their common parentconst favorites = ref([])
function toggle(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <AppHeader :count="favorites.length" /> <ItemList :favorites="favorites" @toggle="toggle" /></template>AppHeader.vue
<script setup>defineProps({ count: { type: Number, required: true } })</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Favoriten: {{ count }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>ItemList.vue
<script setup>import { things } from './things.js'
defineProps({ favorites: { type: Array, required: true } })// The list only reports a click, the parent changes the favoritesconst emit = defineEmits(['toggle'])</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.includes(thing.id)" @click="emit('toggle', thing.id)">Favorit</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },]Laut Vue-Dokumentation wird das in tiefen Bäumen mühsam, weil die Props durch Komponenten wandern, die sie selbst nicht brauchen. Das nennt man Prop Drilling. Für einen Teilbaum helfen provide und inject aus Kapitel 4.5, und so hält es die Fallbeispiel-App seit Teil 6.
Ein Modul für geteilten Zustand
Abschnitt betitelt „Ein Modul für geteilten Zustand“Ein JavaScript-Modul läuft nur einmal, und jede Komponente, die es importiert, bekommt dieselben Objekte. Ein ref in einem Modul ist darum schon ein globaler Zustand, ganz ohne Bibliothek.
App.vue
<script setup>import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'import RequestList from './RequestList.vue'</script>
<template> <AppHeader /> <ItemList /> <RequestList /></template>AppHeader.vue
<script setup>import { requests } from './requests.js'</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Anfragen: {{ requests.length }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>ItemList.vue
<script setup>import { add } from './requests.js'import { things } from './things.js'</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" @click="add(thing)">Anfragen</button> </li> </ul></template>RequestList.vue
<script setup>import { remove, requests } from './requests.js'</script>
<template> <h2>Meine Anfragen</h2> <p v-if="requests.length === 0">Noch keine Anfragen.</p> <ul v-else> <li v-for="request in requests" :key="request.id"> {{ request.name }} <button type="button" @click="remove(request.id)">Zurückziehen</button> </li> </ul></template>requests.js
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through the functions below.export const requests = readonly(list)
export function add(thing) { if (!list.value.some((entry) => entry.id === thing.id)) list.value.push({ id: thing.id, name: thing.name })}
export function remove(id) { list.value = list.value.filter((entry) => entry.id !== id)}things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },]Die Vue-Dokumentation nennt den Haken. Jede Komponente, die das Modul importiert, kann den Zustand beliebig ändern, und in einer grossen App weiss bald niemand mehr, wer was geändert hat. Das Modul im Beispiel gibt die Anfragen darum mit readonly heraus und ändert sie nur in seinen Funktionen add und remove. Für Apps, die auf einem Server gerendert werden, taugt ein solches Modul nicht, weil sich dort alle Anfragen an den Server denselben Zustand teilen würden. Eine SPA wie die des Kurses betrifft das nicht.
Ohne Store auskommen
Abschnitt betitelt „Ohne Store auskommen“Ein Store lohnt sich, wenn viele Komponenten an verschiedenen Stellen denselben Zustand lesen und ändern. Braucht nur eine Komponente einen Wert, bleibt er lokal. Teilen ihn zwei Geschwister, reicht die Elternkomponente. Daten vom Server sind im Browser nur eine Kopie, die veralten kann, denn andere Mitglieder fragen Dinge an, und das Team erfasst Rückgaben. Ein Store kann diese Kopie halten, muss sie aber neu laden, wenn sie veraltet sein kann.
Für die Fallbeispiel-App ergibt das diese Zuordnung.
- Die Favoriten brauchen Kopf, Katalog und die Seite der Favoriten. Sie kommen in einen Store und bleiben im Speicher des Browsers, wie Kapitel 7.4 zeigt.
- Das angemeldete Mitglied brauchen Kopf, Guard und Formular. Es kommt in einen Store, ob die Anmeldung gilt, entscheidet aber der Server.
- Suche, Kategorie und Sortierung bleiben in der Adresse.
- Die Anfragen liegen auf dem Server des Vereins. Ein Store hält die eigenen Anfragen als Kopie für den Kopf, die Seite eines Dings und die Übersicht.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Gib im ersten Beispiel
ItemListwieder eine eigene Liste der Favoriten und nimm die Props weg. Was zeigt der Kopf jetzt? - Versuch im Beispiel mit dem Modul, die Anfragen in
AppHeadermitrequests.value.push({ id: 1 })zu ändern. Was meldet die Konsole? - Ersetze im Modul
readonly(list)durchlistund wiederhole den Versuch. - Ordne die drei Felder des Formulars für Reservationen mit der Abbildung ein. Ändert sich etwas, wenn jemand die halb ausgefüllte Anfrage auch nach dem Neuladen wiederfinden soll?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Favoriten und die Anfragen aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/07-state/where-state/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
ItemList hält die Favoriten selbst, und der Kopf zeigt immer 0. Heb die Favoriten in App. ItemList bekommt sie als Prop favorites und meldet einen Klick auf «Favorit» mit dem Event toggle-favorite. AppHeader bekommt die Zahl als Prop favoriteCount.
Lösung
<script setup>import { ref } from 'vue'
import AppHeader from '@/components/AppHeader.vue'import ItemList from '@/components/ItemList.vue'import RequestList from '@/components/RequestList.vue'
// Header and list both need the favorites, so they live in their common parentconst favorites = ref([])
function toggleFavorite(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <AppHeader :favorite-count="favorites.length" /> <main> <h1>Katalog</h1> <RequestList /> <ItemList :favorites="favorites" @toggle-favorite="toggleFavorite" /> </main></template>ItemList ändert nichts mehr selbst. Es zeigt die Favoriten aus der Prop und meldet jeden Klick mit emit('toggle-favorite', item.id).
Aufgabe Vertiefung
Lege src/requests.js an. Es exportiert die Anfragen als Ref requests mit einem Array und die Funktionen add(item) und remove(id). Der Knopf «Anfragen» in der Liste ruft add auf, der Kopf zeigt die Zahl der Anfragen, und RequestList zeigt sie mit einem Knopf «Zurückziehen».
Lösung
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through add and remove.export const requests = readonly(list)
export function add(item) { if (list.value.some((request) => request.id === item.id)) return list.value.push({ id: item.id, name: item.name })}
export function remove(id) { list.value = list.value.filter((request) => request.id !== id)}Die Lösung enthält schon die Knobelei. Für die Vertiefung genügt export const requests = ref([]) mit den beiden Funktionen. Die Komponenten importieren, was sie brauchen, AppHeader etwa nur requests.
Aufgabe Knobelei
Andere Dateien sollen requests nur lesen können, ändern geht nur über add und remove. Ausserdem steht jedes Ding höchstens einmal in der Liste.
Lösung
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through add and remove.export const requests = readonly(list)
export function add(item) { if (list.value.some((request) => request.id === item.id)) return list.value.push({ id: item.id, name: item.name })}Das Modul behält das beschreibbare list für sich und exportiert nur die Ansicht von readonly. Ein Schreibversuch von aussen ändert nichts, und Vue warnt in der Konsole.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Zwei Komponenten zeigen verschiedene Favoriten. | Jede hält eine eigene Kopie. Den Zustand in die gemeinsame Elternkomponente heben. |
| Vue warnt, weil eine Kindkomponente ihre Prop ändert. | Das Kind meldet ein Event, und die Elternkomponente ändert ihren Zustand. |
| Viele Komponenten ändern den Zustand eines Moduls, und niemand weiss, wer was geändert hat. | Den Zustand mit readonly herausgeben und nur in Funktionen des Moduls ändern. |
| Der Filter liegt in einem globalen Zustand und fehlt in einem geteilten Link. | Was man teilen und neu laden will, gehört in die Adresse. |
| Ein KI-Assistent legt für jeden Wert einen Store an, auch für einen offenen Dialog. | Lokaler Zustand bleibt in der Komponente. Ein Store lohnt sich für Zustand, den viele Teile der App brauchen. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- In welcher Reihenfolge fragt die Abbildung, und warum steht die Adresse zuerst?
- Was ist Prop Drilling, und was hilft dagegen?
- Warum gibt das Modul die Anfragen mit
readonlyheraus? - Warum ist der Katalog im Browser nur eine Kopie?
Antworten
- Adresse, Server, eine Komponente, wenige Komponenten, sonst ein Store. Was jemand teilen oder neu laden will, muss in der Adresse stehen, egal wie viele Komponenten es brauchen.
- Props wandern durch Komponenten, die sie nicht brauchen, nur um tiefer liegende Kinder zu erreichen.
provideundinject, ein Modul oder ein Store helfen. - So ändert sich der Zustand nur in den Funktionen des Moduls, und ein Fehler lässt sich auf wenige Stellen eingrenzen.
- Er gehört dem Server des Vereins. Andere Mitglieder und das Team ändern ihn, und die App sieht das erst beim nächsten Laden.
Auftrag
Die Zuordnung steht. Für Anmeldung und Anfragen will der Freiwillige aber mehr als ein Modul. «Ich möchte in den Entwicklertools sehen, was sich wann ändert, und den Zustand in Tests leicht vorbereiten können.» Das bietet Pinia, die Bibliothek für Stores, die das nächste Kapitel vorstellt.
Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.