Zum Inhalt springen

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:

  1. Für Favoriten, Anmeldung, Filter und Anfragen ist entschieden und begründet, wo sie liegen.
  2. Kopf und Liste zeigen dieselben Favoriten, und keine Komponente hält eine eigene Kopie.
  3. 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.

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.

ArtBeispiel in der Fallbeispiel-AppOrt
lokalob der Dialog offen istein ref in der Komponente
geteiltdie Favoriten für Kopf und Listedie gemeinsame Elternkomponente
globaldas angemeldete Mitgliedein Modul oder ein Store
vom ServerKatalog und Anfragender Server, im Browser eine Kopie
in der AdresseSuche, Kategorie und SortierungParameter 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.

Wohin ein Zustand gehört Ein Entscheidungsbaum mit vier Fragen von oben nach unten. Soll eine Adresse den Wert festhalten, zum Teilen und Neuladen, gehört er in die Adresse, als route.params oder route.query. Liegt er sonst auf dem Server, holt ihn ein Store mit fetch in einer Action. Braucht ihn sonst nur eine Komponente, bleibt er lokal als ref in der Komponente. Brauchen ihn sonst wenige Komponenten mit gemeinsamer Elternkomponente, kommt er in die Elternkomponente und geht mit Props und Events weiter. In allen anderen Fällen gehört er in einen Store mit Pinia. Soll eine Adresse den Wert festhalten, zum Teilen und Neuladen? ja In die Adresse route.params, route.query nein Liegt der Wert auf dem Server, etwa Katalog und Anfragen? ja Ein Store fragt den Server fetch in einer Action nein Braucht ihn nur eine Komponente, etwa ein offener Dialog? ja Lokal in der Komponente ref nein Brauchen ihn wenige Komponenten mit gemeinsamer Elternkomponente? ja In die Elternkomponente Props und Events nein In einen Store Pinia

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

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 parent
const 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

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

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 favorites
const 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

things.js
// A few things of the catalog
export 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 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

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

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

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

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

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

things.js
// A few things of the catalog
export 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.

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.

  1. 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.
  2. Das angemeldete Mitglied brauchen Kopf, Guard und Formular. Es kommt in einen Store, ob die Anmeldung gilt, entscheidet aber der Server.
  3. Suche, Kategorie und Sortierung bleiben in der Adresse.
  4. 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.

Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.

  1. Gib im ersten Beispiel ItemList wieder eine eigene Liste der Favoriten und nimm die Props weg. Was zeigt der Kopf jetzt?
  2. Versuch im Beispiel mit dem Modul, die Anfragen in AppHeader mit requests.value.push({ id: 1 }) zu ändern. Was meldet die Konsole?
  3. Ersetze im Modul readonly(list) durch list und wiederhole den Versuch.
  4. 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?

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.

Terminal-Fenster
npm install
npm test

Aufgabe 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
App.vue
<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 parent
const 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
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 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
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 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.

FehlerWas 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

  1. In welcher Reihenfolge fragt die Abbildung, und warum steht die Adresse zuerst?
  2. Was ist Prop Drilling, und was hilft dagegen?
  3. Warum gibt das Modul die Anfragen mit readonly heraus?
  4. Warum ist der Katalog im Browser nur eine Kopie?
Antworten
  1. 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.
  2. Props wandern durch Komponenten, die sie nicht brauchen, nur um tiefer liegende Kinder zu erreichen. provide und inject, ein Modul oder ein Store helfen.
  3. So ändert sich der Zustand nur in den Funktionen des Moduls, und ein Fehler lässt sich auf wenige Stellen eingrenzen.
  4. 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.