Pinia-Tutorial: globales State Management in Vue 3

Pinia-Tutorial: globales State Management in Vue 3
Schnelle Antwort

Pinia ist die empfohlene Bibliothek für State Management in Vue 3, Vuex ist im Wartungsmodus. Einen Store deklarierst du mit defineStore, nutzt ihn, indem du usePanierStore() in einer beliebigen Komponente aufrufst, und destrukturierst ihn mit storeToRefs, um die Reaktivität nicht zu verlieren.

Ein Event von einer Komponente an ihr Elternteil weiterzureichen, funktioniert über ein oder zwei Ebenen gut. Darüber hinaus verbringst du deine Zeit damit, Events erneut zu emittieren und Props durch Komponenten zu reichen, die nichts damit anfangen. Pinia löst genau das: ein Zustand, einmal deklariert, aus jeder Komponente lesbar und änderbar.

Pinia oder Vuex?

Die Frage stellt sich nicht mehr. Vuex ist im Wartungsmodus, und die offizielle Vue-Dokumentation verweist auf Pinia, die inzwischen empfohlene Bibliothek für State Management. Für ein neues Projekt nimmst du ohne Zögern Pinia. Für ein laufendes Vuex-Projekt eilt die Migration nicht: Sie läuft Store für Store, beide vertragen sich nebeneinander.

Was Pinia in der Praxis ändert: keine Mutations mehr, nur noch Actions; keine verschachtelten Module mehr, jeder Store ist ein Modul, und eine Typisierung, die ohne Verrenkungen funktioniert.

Installieren

bash
npm install pinia

Dann hängst du das Plugin in die Anwendung ein:

src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)

app.use(createPinia())
app.mount('#app')

Bei einem neuen Projekt übernimmt der offizielle Generator das: Antworte bei npm create vue@latest mit „ja“ auf die Pinia-Frage.

Einen Store schreiben

Einen Store deklarierst du mit defineStore. Das erste Argument ist ein eindeutiger Identifier, den die Devtools verwenden. Das zweite kann zwei Formen annehmen, die Setup-Form ist die, die das offizielle Tooling erzeugt, und die, die einer Komponente am nächsten kommt.

src/stores/panier.js
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const usePanierStore = defineStore('panier', () => {
  // State: refs
  const lignes = ref([])

  // Getter: computed
  const total = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.prix * l.quantite, 0)
  )
  const nombreArticles = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.quantite, 0)
  )

  // Actions: Funktionen
  function ajouter(produit) {
    const ligne = lignes.value.find((l) => l.id === produit.id)
    if (ligne) {
      ligne.quantite += 1
      return
    }
    lignes.value.push({ ...produit, quantite: 1 })
  }

  function retirer(id) {
    lignes.value = lignes.value.filter((l) => l.id !== id)
  }

  function vider() {
    lignes.value = []
  }

  return { lignes, total, nombreArticles, ajouter, retirer, vider }
})

Alles, was die Funktion zurückgibt, wird öffentlich. Was sie nicht zurückgibt, bleibt privat im Store: So behältst du dort einen internen Zähler oder eine Hilfsfunktion für dich.

Die Namenskonvention lautet useQuelqueChoseStore, wie bei einem Composable.

Die Options-Form

Die andere Schreibweise übernimmt die Struktur von Vuex, ohne die Mutations:

src/stores/panier.js
export const usePanierStore = defineStore('panier', {
  state: () => ({ lignes: [] }),
  getters: {
    total: (state) => state.lignes.reduce((s, l) => s + l.prix * l.quantite, 0)
  },
  actions: {
    ajouter(produit) {
      this.lignes.push({ ...produit, quantite: 1 })
    }
  }
})

Beide Formen sind gleichwertig und austauschbar. Die Options-Form erleichtert den Umstieg von Vuex, die Setup-Form gibt mehr Freiheit, etwa um ein Composable innerhalb des Stores zu verwenden.

Den Store benutzen

Ruf die Funktion in der Komponente auf. Die Instanz ist einzigartig: Zwei Komponenten, die usePanierStore() aufrufen, bekommen exakt dasselbe Objekt.

src/components/Panier.vue
<script setup>
import { usePanierStore } from '@/stores/panier'

const panier = usePanierStore()
</script>

<template>
  <p>{{ panier.nombreArticles }} article(s) — {{ panier.total }} €</p>

  <ul>
    <li v-for="ligne in panier.lignes" :key="ligne.id">
      {{ ligne.nom }} × {{ ligne.quantite }}
      <button @click="panier.retirer(ligne.id)">Retirer</button>
    </li>
  </ul>

  <button @click="panier.vider()">Vider</button>
</template>

State, Getter und Actions erreichst du direkt auf dem Objekt, ohne Umweg über einen Vermittler.

storeToRefs, oder die verlorene Reaktivität

Das ist der häufigste Fehler mit Pinia. Den Store zu destrukturieren liefert eingefrorene Werte:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ eingefrorene Zahl, ändert sich nicht mehr
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs extrahiert State und Getter und erhält dabei die Reaktivität:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ refs
const { ajouter, retirer } = panier             // ✓ Actions lassen sich destrukturieren

panier.ajouter({ id: 1, prix: 7 })
console.log(total.value)                        // 7

Merk dir die Regel: State und Getter laufen über storeToRefs, Actions destrukturierst du direkt. Actions sind schlichte Funktionen, die an den Store gebunden sind, sie verlieren dabei nichts.

Den State verändern

Drei Wege, geordnet nach Vorzug.

Eine Action für jede Fachlogik. Das ist der Normalfall: Die Regel steht einmal geschrieben im Store.

javascript
panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

$patch, um mehrere Einträge in einem einzigen Vorgang zu ändern, was in den Devtools nur einen Eintrag erzeugt:

javascript
panier.$patch({ lignes: [], codePromo: null })

// Funktionsform, für Arrays
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

Die direkte Zuweisung ist erlaubt, anders als bei Vuex, aber heb sie dir für triviale Fälle auf:

javascript
panier.codePromo = 'RENTREE'
$reset gibt es auf einem Setup-Store nicht

Bei einem Store in Options-Form stellt panier.$reset() den Ausgangszustand wieder her. Bei einem Store in Setup-Form kennt Pinia diesen Ausgangszustand nicht: Der Aufruf wirft einen Fehler. Schreib deine eigene Action zum Zurücksetzen, so wie die Funktion vider() weiter oben.

Änderungen beobachten

$subscribe wird bei jeder Änderung des State aufgerufen. Das ist der Hook, um den Warenkorb zu persistieren:

javascript
panier.$subscribe((mutation, state) => {
  localStorage.setItem('panier', JSON.stringify(state.lignes))
})

Für einen breiteren Persistenzbedarf erledigt ein Pinia-Plugin die Arbeit über alle Stores hinweg. Ein Plugin ist eine Funktion, die den Kontext erhält und jeden Store erweitert:

src/stores/plugin-persistance.js
export function persistance({ store }) {
  const sauvegarde = localStorage.getItem(store.$id)
  if (sauvegarde) {
    store.$patch(JSON.parse(sauvegarde))
  }

  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}
src/main.js
const pinia = createPinia()
pinia.use(persistance)

Einen Store testen

Einen Store testest du, ohne eine Komponente zu mounten. Du musst nur vor jedem Test eine frische Pinia-Instanz aktivieren, um von einem leeren Zustand aus zu starten:

src/stores/__tests__/panier.spec.js
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { usePanierStore } from '../panier.js'

describe('store panier', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('cumule les quantités du même produit', () => {
    const panier = usePanierStore()
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

    expect(panier.lignes).toHaveLength(1)
    expect(panier.nombreArticles).toBe(2)
    expect(panier.total).toBe(50)
  })
})

Ohne setActivePinia schlägt der Aufruf des Stores mit der Meldung „“getActivePinia()“ was called but there was no active Pinia. Are you trying to use a store before calling „app.use(pinia)“?“ fehl.

Wann du Pinia nicht einsetzen solltest

Ein Store ist nicht umsonst: Er bringt eine Datei mehr, eine Indirektion und einen weiteren globalen Zustand mit. Er lohnt sich, wenn Daten von weit auseinanderliegenden Komponenten genutzt werden oder das Unmounten einer Komponente überleben müssen.

Für Daten aus einem Echtzeitdienst schaust du dir besser Firebase und VueFire an, deren Bindings bereits reaktiv sind.

Für den lokalen Zustand eines Formulars genügt ein ref. Für einen Zustand, den ein Elternteil mit seinem direkten Kind teilt, erledigen Props und Events die Arbeit. Für das Caching von Serverdaten passt ein spezialisiertes Werkzeug wie TanStack Query besser.

Wenn Vue neu für dich ist, fang mit dem Vue-3-Tutorial für Einsteiger an: Pinia ergibt erst dann richtig Sinn, wenn Komponenten und Events sitzen.

Häufige Fehler

Den Store destrukturieren const { total } = panier liefert einen eingefrorenen Wert. Nimm storeToRefs(panier) für State und Getter, Actions dagegen destrukturierst du direkt.n$reset auf einem Setup-Store | Pinia kennt den Ausgangszustand eines in Setup-Form geschriebenen Stores nicht: Der Aufruf wirft einen Fehler. Schreib deine eigene Action zum Zurücksetzen.
Einen Store ohne aktives Pinia außerhalb einer Komponente aufrufen Es erscheint der Fehler „getActivePinia was called with no active Pinia“. Rufe im Test vor jedem Fall setActivePinia(createPinia()) auf.
Den gesamten Anwendungszustand in einen Store packen Für ein Formular genügt ein lokales ref, und für ein Elternteil mit seinem direkten Kind reichen Props oder Events.

JavaScriptPiniaVue.js

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.