Zum Inhalt springen

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:

  1. Anmeldung und Anfragen liegen in je einem Store.
  2. Kopf, Seite eines Dings und Guard lesen die Anmeldung aus dem Store.
  3. 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 storeToRefs zerlegen, ohne die Verbindung zu verlieren,
  • einen Store in einem anderen Store und in einem Navigation Guard verwenden,
  • Stores und Komponenten mit Stores testen.

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')

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

App.vue
<script setup>
import AppHeader from './AppHeader.vue'
import ItemList from './ItemList.vue'
</script>
<template>
<AppHeader />
<ItemList />
</template>

AppHeader.vue

AppHeader.vue
<script setup>
import { useFavoritesStore } from './favorites.js'
// Every component that calls the function gets the same store
const 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

favorites.js
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actions
export 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

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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

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 },
]

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.

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

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 moment
const { count: copiedCount } = favorites
// storeToRefs makes a ref of every piece of state and every getter
const { count } = storeToRefs(favorites)
// Actions belong to the store and can be destructured directly
const { 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

favorites.js
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actions
export 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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

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 },
]

Actions kann man direkt zerlegen, denn sie gehören laut Dokumentation fest zum Store. storeToRefs lässt sie aus.

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

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

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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

requests.js

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

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 },
]

Pinia gibt jedem Aufruf von useAuthStore() denselben Store, auch im anderen Store. Meldet sich jemand anderes an, rechnet mine neu.

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

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

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

CatalogView.vue
<template>
<h1>Katalog</h1>
<p>60 Dinge zum Ausleihen.</p>
</template>

LoginView.vue

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

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 it
createApp(App).use(createPinia()).use(router).mount('#app')

router.js

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 router

TeamView.vue

TeamView.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.

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.

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.

level-2.test.js
let pinia
// A fresh Pinia for every test, the router guard uses it as well
beforeEach(() => {
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.

level-2.test.js
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)
})

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

  1. Nimm im ersten Beispiel count aus dem return des Stores. Was zeigt der Kopf jetzt?
  2. Klick im Beispiel mit storeToRefs mehrmals auf «Favorit». Welche Zahl bleibt stehen, und warum?
  3. 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»?
  4. Verschieb im Beispiel mit dem Guard die Zeile const auth = useAuthStore() an den Anfang des Moduls, vor createRouter. Was passiert beim Start?

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.

Terminal-Fenster
npm install
npm test

Aufgabe 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
auth.js
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 API
const 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 }
})
index.js
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
requests.js
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
requests.js
// A setup store has no $reset of its own, this one empties the list
function $reset() {
list.value = []
}
auth.js
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.

FehlerWas 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

  1. Was wird in einem Setup Store zu State, zu Getters und zu Actions?
  2. Warum verliert ein direkt zerlegter Store die Verbindung, und was hilft?
  3. Wo ruft ein Navigation Guard seinen Store auf, und warum?
  4. Was tut createTestingPinia mit den Actions eines Stores?
Antworten
  1. Refs werden zu State, berechnete Werte zu Getters und Funktionen zu Actions.
  2. Der Store ist ein reaktives Objekt. Beim Zerlegen entstehen Kopien der Werte. storeToRefs macht Refs, die mit dem Store verbunden bleiben.
  3. In seiner Funktion. Dann ist Pinia sicher angemeldet, oben im Modul vielleicht noch nicht.
  4. 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.