Pinia
Auftrag
Martina Hostettler meldet sich aus dem Tresen.
Als Freiwillige oder Freiwilliger am Tresen möchte ich, dass nach dem Abmelden nichts mehr von den Anfragen des vorigen Mitglieds zu sehen ist, damit das nächste Mitglied am selben Gerät nur seine eigenen sieht.
Der Freiwillige schlägt vor, Anmeldung und Anfragen in Stores mit Pinia zu legen. Die Akzeptanzkriterien lauten:
- Anmeldung und Anfragen liegen in je einem Store.
- Kopf, Seite eines Dings und Guard lesen die Anmeldung aus dem Store.
- Nach dem Abmelden steht keine Anfrage des vorigen Mitglieds mehr im Speicher.
Lernziele
Nach diesem Kapitel kannst du
- Pinia in einer App einrichten,
- einen Setup Store mit State, Getters und Actions schreiben,
- einen Store mit
storeToRefszerlegen, ohne die Verbindung zu verlieren, - einen Store in einem anderen Store und in einem Navigation Guard verwenden,
- Stores und Komponenten mit Stores testen.
Pinia einrichten
Abschnitt betitelt „Pinia einrichten“Pinia ist die Bibliothek für Stores, die Vue empfiehlt. Laut Vue-Dokumentation pflegt sie das Kernteam von Vue, und das ältere Vuex bekommt keine neuen Funktionen mehr. Gegenüber einem Modul wie im letzten Kapitel bringt Pinia feste Regeln für die Zusammenarbeit und eine Anbindung an die Vue DevTools mit.
Ein Projekt von create-vue mit der Option Pinia hat schon alles, was es braucht. Pinia steht in den Abhängigkeiten, ein Beispielstore liegt in src/stores, und main.js meldet Pinia vor dem Router an.
const app = createApp(App)
app.use(createPinia())app.use(router)
app.mount('#app')Setup Stores
Abschnitt betitelt „Setup Stores“defineStore legt einen Store an. Das erste Argument ist eine id, die in der App eindeutig sein muss. Das zweite ist eine Funktion wie <script setup>. Laut Pinia-Dokumentation werden darin Refs zum State, berechnete Werte zu Getters und Funktionen zu Actions. Der Store heisst nach Konvention use plus Name plus Store.
App.vue
<script setup>import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'</script>
<template> <AppHeader /> <ItemList /></template>AppHeader.vue
<script setup>import { useFavoritesStore } from './favorites.js'
// Every component that calls the function gets the same storeconst favorites = useFavoritesStore()</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Favoriten: {{ favorites.count }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actionsexport const useFavoritesStore = defineStore('favorites', () => { const ids = ref([]) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
// A setup store returns all of its state, otherwise Pinia and the devtools miss it return { ids, count, isFavorite, toggle }})ItemList.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorit</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')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 },]Jede Komponente, die useFavoritesStore() aufruft, bekommt denselben Store. Ein Setup Store muss allen State zurückgeben, sonst verpassen ihn laut Dokumentation Pinia und die Entwicklertools. Privaten State gibt es darum nicht. Neben Setup Stores kennt Pinia Option Stores mit den Feldern state, getters und actions, wie die Options API. Der Kurs verwendet Setup Stores, weil sie wie <script setup> aussehen und Composables nutzen können.
Stores zerlegen mit storeToRefs
Abschnitt betitelt „Stores zerlegen mit storeToRefs“Ein Store ist ein reaktives Objekt. Zerlegt man ihn direkt, bekommt man Kopien der Werte, wie bei reactive in Kapitel 5.1. storeToRefs macht aus State und Getters Refs, die mit dem Store verbunden bleiben.
App.vue
<script setup>import { storeToRefs } from 'pinia'
import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()// Destructured directly, copiedCount keeps the number of this momentconst { count: copiedCount } = favorites// storeToRefs makes a ref of every piece of state and every getterconst { count } = storeToRefs(favorites)// Actions belong to the store and can be destructured directlyconst { toggle } = favorites</script>
<template> <p>Direkt zerlegt: {{ copiedCount }}</p> <p>Mit storeToRefs: {{ count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" @click="toggle(thing.id)">Favorit</button> </li> </ul></template>favorites.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actionsexport const useFavoritesStore = defineStore('favorites', () => { const ids = ref([]) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
// A setup store returns all of its state, otherwise Pinia and the devtools miss it return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')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 },]Actions kann man direkt zerlegen, denn sie gehören laut Dokumentation fest zum Store. storeToRefs lässt sie aus.
Stores, die andere Stores nutzen
Abschnitt betitelt „Stores, die andere Stores nutzen“Ein Store darf einen anderen Store verwenden. Der Store der Anfragen fragt den Store der Anmeldung, wer gerade angemeldet ist, und zeigt in seinem Getter mine nur dessen Anfragen.
App.vue
<script setup>import { storeToRefs } from 'pinia'
import { useAuthStore } from './auth.js'import { useRequestsStore } from './requests.js'import { things } from './things.js'
const auth = useAuthStore()const requests = useRequestsStore()const { member } = storeToRefs(auth)const { mine } = storeToRefs(requests)</script>
<template> <p v-if="member"> Angemeldet als {{ member.name }} <button type="button" @click="auth.signOut()">Abmelden</button> </p> <p v-else> <button type="button" @click="auth.signIn('M-117')">Als M-117 anmelden</button> <button type="button" @click="auth.signIn('M-204')">Als M-204 anmelden</button> </p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :disabled="!member" @click="requests.add(thing)">Anfragen</button> </li> </ul> <h2>Meine Anfragen</h2> <ul> <li v-for="request in mine" :key="request.id">{{ request.name }}</li> </ul></template>auth.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A stand-in for the members of the association until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'Mitglied M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number) ?? null return member.value !== null }
function signOut() { member.value = null }
return { member, isTeam, signIn, signOut }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')requests.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useAuthStore } from './auth.js'
export const useRequestsStore = defineStore('requests', () => { // A store can use another store, Pinia hands out the same login everywhere const auth = useAuthStore() const all = ref([]) const mine = computed(() => all.value.filter((request) => request.member === auth.member?.number))
function add(thing) { if (auth.member === null || mine.value.some((request) => request.id === thing.id)) return all.value.push({ member: auth.member.number, id: thing.id, name: thing.name }) }
return { all, mine, add }})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 },]Pinia gibt jedem Aufruf von useAuthStore() denselben Store, auch im anderen Store. Meldet sich jemand anderes an, rechnet mine neu.
Ein Store im Navigation Guard
Abschnitt betitelt „Ein Store im Navigation Guard“Ein Guard läuft ausserhalb von Komponenten. Laut Pinia-Dokumentation ruft er den Store darum in seiner Funktion auf und nicht oben im Modul. Dort liefe der Aufruf je nach Reihenfolge der Importe, bevor Pinia angemeldet ist, und schlüge fehl.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { useAuthStore } from './auth.js'
const auth = useAuthStore()const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <button v-if="auth.member" type="button" @click="auth.signOut()">Abmelden</button> </nav> <p>Adresse: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A stand-in for the members of the association until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'Mitglied M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number) ?? null return member.value !== null }
function signOut() { member.value = null }
return { member, isTeam, signIn, signOut }})CatalogView.vue
<template> <h1>Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>LoginView.vue
<script setup>import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './auth.js'
const auth = useAuthStore()const route = useRoute()const router = useRouter()
function signIn(number) { auth.signIn(number) const redirect = route.query.redirect router.replace(typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/')}</script>
<template> <h1>Anmelden</h1> <p v-if="auth.member && !auth.isTeam">Für diesen Bereich braucht es ein Konto des Teams.</p> <button type="button" @click="signIn('M-117')">Als M-117 anmelden</button> <button type="button" @click="signIn('M-204')">Als M-204 anmelden</button></template>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
// Pinia comes first, the router starts navigating as soon as the app uses itcreateApp(App).use(createPinia()).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { useAuthStore } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', name: 'login', component: LoginView }, { path: '/team', component: TeamView, meta: { requiresTeam: true } }, ],})
router.beforeEach((to) => { // Inside the guard, when Pinia is already installed, not at the top of the module const auth = useAuthStore() if (to.meta.requiresTeam && !auth.isTeam) { return { name: 'login', query: { redirect: to.fullPath } } }})
export default routerTeamView.vue
<template> <h1>Team am Tresen</h1> <p>Nur für das Team.</p></template>Ein Mitglied ohne Konto des Teams landet wie in Kapitel 6.4 auf der Anmeldung, die Information kommt jetzt aus dem Store.
Stores in den Entwicklertools
Abschnitt betitelt „Stores in den Entwicklertools“Laut Vue-Dokumentation bindet Pinia die Vue DevTools ein, mit einer Zeitleiste der Änderungen und einem Blick in die Stores, die eine Komponente nutzt. Die id eines Stores ist sein Name in den Entwicklertools. Ein Projekt von create-vue bringt die Vue DevTools als Plugin von Vite mit, der Knopf dafür erscheint unten auf der Seite, solange npm run dev läuft.
Stores testen
Abschnitt betitelt „Stores testen“Ein Store lässt sich ohne Komponente testen. Laut Pinia-Dokumentation legt setActivePinia(createPinia()) vor jedem Test ein frisches Pinia an, und useAuthStore() liefert danach einen frischen Store.
let pinia
// A fresh Pinia for every test, the router guard uses it as wellbeforeEach(() => { pinia = createPinia() setActivePinia(pinia)})Für Komponenten gibt es createTestingPinia aus dem Paket @pinia/testing. Es ersetzt alle Actions durch Spione, die nur ihre Aufrufe notieren, und nimmt den State, mit dem ein Test beginnen soll. Mit Vitest ohne globale Funktionen braucht es dazu createSpy: vi.fn.
it('ruft beim Klick die Action des Stores auf', async () => { // createTestingPinia replaces every action with a spy and takes the state to start from const testing = createTestingPinia({ createSpy: vi.fn, initialState: { auth: { member: { number: 'M-117', name: 'Martina Hostettler', team: true } } }, }) await router.push('/items/6') const wrapper = mount(App, { global: { plugins: [router, testing] } }) await flushPromises() await wrapper.find('button.request').trigger('click') expect(useRequestsStore(testing).add).toHaveBeenCalledWith(6) })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.
- Nimm im ersten Beispiel
countaus demreturndes Stores. Was zeigt der Kopf jetzt? - Klick im Beispiel mit
storeToRefsmehrmals auf «Favorit». Welche Zahl bleibt stehen, und warum? - Melde dich im Beispiel mit den zwei Stores als M-117 an, frag zwei Dinge an, melde dich ab und als M-204 an. Was zeigt «Meine Anfragen»?
- Verschieb im Beispiel mit dem Guard die Zeile
const auth = useAuthStore()an den Anfang des Moduls, vorcreateRouter. Was passiert beim Start?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die beiden Stores aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/07-state/pinia/de. Die Anmeldung liegt dort noch als Modul in src/auth.js, wie in Teil 6. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Mach aus src/auth.js den Store useAuthStore mit der id auth in src/stores/auth.js. Er hat den State member, den Getter isTeam und die Actions signIn(number) und signOut(). signIn gibt true zurück, wenn die Nummer bekannt ist. Kopf, Anmeldung und Guard verwenden den Store, danach löschst du src/auth.js.
Lösung
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useRequestsStore } from './requests.js'
// A stand-in for the members of the association, until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'Mitglied M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number.trim().toUpperCase()) ?? null return member.value !== null }
function signOut() { member.value = null // Several members use the same device at the counter, nothing of the last one stays behind useRequestsStore().$reset() }
return { member, isTeam, signIn, signOut }})router.beforeEach((to) => { // Inside the guard, Pinia is installed by then const auth = useAuthStore() if (to.meta.requiresTeam && !auth.isTeam) { return { name: 'login', query: { redirect: to.fullPath } } }})Die Lösung enthält mit der Zeile useRequestsStore().$reset() schon die Knobelei. Im Kopf zerlegt storeToRefs(auth) den Store, die Action signOut ruft er über auth.signOut() auf.
Aufgabe Vertiefung
Lege den Store useRequestsStore mit der id requests in src/stores/requests.js an. Er hat den State list, den Getter mine mit den Anfragen des angemeldeten Mitglieds und die Action add(itemId). Ohne Anmeldung nimmt add nichts an, und jedes Mitglied fragt ein Ding höchstens einmal an. Der Knopf «Anfragen» auf der Seite eines Dings ruft add auf, und der Kopf zeigt die Zahl der eigenen Anfragen.
Lösung
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useAuthStore } from './auth.js'
export const useRequestsStore = defineStore('requests', () => { const auth = useAuthStore() const list = ref([]) // The requests of whoever is signed in const mine = computed(() => list.value.filter((request) => request.member === auth.member?.number))
function add(itemId) { if (auth.member === null || mine.value.some((request) => request.itemId === itemId)) return list.value.push({ itemId, member: auth.member.number }) }
// A setup store has no $reset of its own, this one empties the list function $reset() { list.value = [] }
return { list, mine, add, $reset }})Der Store fragt den Store der Anmeldung in seiner Funktion. Auf der Seite eines Dings zeigt ein berechneter Wert mit requests.mine, ob das Ding schon angefragt ist, und der Knopf heisst dann «Angefragt».
Aufgabe Knobelei
Am Tresen melden sich nacheinander mehrere Mitglieder an. Gib dem Store der Anfragen eine Action $reset, die die Liste leert, und ruf sie beim Abmelden auf.
Lösung
// A setup store has no $reset of its own, this one empties the list function $reset() { list.value = [] } function signOut() { member.value = null // Several members use the same device at the counter, nothing of the last one stays behind useRequestsStore().$reset() }Ein Option Store hat $reset von Pinia, ein Setup Store braucht laut Dokumentation eine eigene Action mit diesem Namen. Die zwei Stores importieren sich gegenseitig. Das geht, weil beide den anderen Store erst in ihren Funktionen aufrufen.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Die Konsole meldet, dass es kein aktives Pinia gibt. | In main.js fehlt app.use(createPinia()), oder ein Modul ruft den Store schon beim Laden auf. Den Store erst in einer Funktion aufrufen. |
| Ein Wert aus dem Store bleibt in der Komponente stehen. | Der Store wurde direkt zerlegt. storeToRefs verwenden oder über den Store lesen, etwa auth.member. |
| Ein Wert erscheint nicht in den Entwicklertools. | Der Setup Store gibt ihn nicht zurück. Allen State zurückgeben. |
store.$reset() meldet einen Fehler. | Setup Stores haben kein eigenes $reset. Eine Action mit diesem Namen schreiben. |
Ein KI-Assistent schreibt einen Store mit new Vuex.Store. | Das ist Vuex für ältere Apps. Neue Apps verwenden Pinia mit defineStore. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Was wird in einem Setup Store zu State, zu Getters und zu Actions?
- Warum verliert ein direkt zerlegter Store die Verbindung, und was hilft?
- Wo ruft ein Navigation Guard seinen Store auf, und warum?
- Was tut
createTestingPiniamit den Actions eines Stores?
Antworten
- Refs werden zu State, berechnete Werte zu Getters und Funktionen zu Actions.
- Der Store ist ein reaktives Objekt. Beim Zerlegen entstehen Kopien der Werte.
storeToRefsmacht Refs, die mit dem Store verbunden bleiben. - In seiner Funktion. Dann ist Pinia sicher angemeldet, oben im Modul vielleicht noch nicht.
- Es ersetzt sie durch Spione, die nur ihre Aufrufe notieren, ausser mit
stubActions: false.
Auftrag
Anmeldung und Anfragen haben ihre Stores, und am Tresen bleibt nach dem Abmelden nichts zurück. Noch liegen die Anfragen aber nur im Speicher des Browsers, und die Anmeldung kennt zwei Mitglieder aus dem Code. Das Team will die Anfragen sehen, und der Katalog soll vom Server des Vereins kommen. Darum geht es im nächsten Kapitel.
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.