Pinia in Vue 3: globale state management van store tot test

Pinia in Vue 3: globale state management van store tot test
Kort antwoord

Pinia is de aanbevolen library voor state management in Vue 3, Vuex staat in onderhoudsmodus. Een store declareer je met defineStore, gebruik je door usePanierStore() in een willekeurige component aan te roepen, en destructureer je met storeToRefs om de reactiviteit niet te verliezen.

Een event van een component naar zijn parent doorsturen werkt prima over één of twee niveaus. Daarboven ben je vooral bezig events opnieuw uit te zenden en props omlaag te duwen door componenten die er niets mee doen. Pinia lost dat op: state die je één keer declareert en die vanuit elke component te lezen en aan te passen is.

Pinia of Vuex?

Die vraag speelt niet meer. Vuex staat in onderhoudsmodus en de officiële Vue-documentatie verwijst naar Pinia, inmiddels de aanbevolen library voor state management. Voor een nieuw project neem je Pinia zonder aarzelen. Draait je project op Vuex en werkt het, dan heeft migreren geen haast: het gaat store voor store en de twee kunnen naast elkaar bestaan.

Wat Pinia in de praktijk verandert: geen mutations meer, alleen actions; geen geneste modules meer, elke store ís een module, en typing die werkt zonder bochtenwerk.

Installeren

bash
npm install pinia

Hang daarna de plugin aan de applicatie:

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

Bij een nieuw project regelt de officiële generator dat: antwoord “ja” op Pinia tijdens npm create vue@latest.

Een store schrijven

Een store declareer je met defineStore. Het eerste argument is een unieke id, die de devtools gebruiken. Het tweede kan twee vormen aannemen, de setup-vorm is wat de officiële tooling genereert en lijkt het meest op een component.

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

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

  // getters: 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: functies
  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 wat de functie teruggeeft, wordt publiek. Wat ze niet teruggeeft blijft privé binnen de store: zo houd je er een interne teller of een hulpfunctie in.

De naamgevingsconventie is useQuelqueChoseStore, net als bij een composable.

De options-vorm

De andere schrijfwijze neemt de structuur van Vuex over, zonder de 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 vormen zijn gelijkwaardig en uitwisselbaar. De options-vorm maakt de overstap vanaf Vuex makkelijker, de setup-vorm geeft meer vrijheid, bijvoorbeeld om een composable binnen de store te gebruiken.

De store gebruiken

Roep de functie aan in de component. De instantie is uniek: twee componenten die usePanierStore() aanroepen, krijgen precies hetzelfde object.

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, getters en actions bereik je rechtstreeks op dat object, zonder tussenlaag.

storeToRefs, of de verloren reactiviteit

Dit is de meest gemaakte fout met Pinia. De store destructureren levert bevroren waarden op:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ bevroren getal, verandert niet meer
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs haalt state en getters eruit met behoud van reactiviteit:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ refs
const { ajouter, retirer } = panier             // ✓ actions mag je destructureren

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

Onthoud de regel: state en getters gaan door storeToRefs, actions destructureer je rechtstreeks. Actions zijn gewone functies die aan de store gebonden zijn, die verliezen niets.

De state wijzigen

Drie manieren, op volgorde van voorkeur.

Een action voor alle businesslogica. Dat is het normale geval: de regel staat één keer opgeschreven, in de store.

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

$patch om meerdere velden in één operatie te wijzigen, wat in de devtools maar één regel oplevert:

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

// functievorm, voor arrays
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

Rechtstreeks toewijzen mag, anders dan bij Vuex, maar houd het bij triviale gevallen:

javascript
panier.codePromo = 'RENTREE'
$reset bestaat niet op een setup-store

Bij een store in de options-vorm zet panier.$reset() de beginstaat terug. Bij een setup-store kent Pinia die beginstaat niet: de aanroep gooit een fout. Schrijf je eigen reset-action, zoals de functie vider() hierboven.

Wijzigingen volgen

$subscribe wordt bij elke wijziging van de state aangeroepen. Dat is de hook om het winkelmandje te bewaren:

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

Heb je bredere persistentie nodig, dan doet een Pinia-plugin het werk voor alle stores. Een plugin is een functie die de context krijgt en elke store verrijkt:

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)

Een store testen

Een store test je zonder een component te mounten. Je hoeft alleen vóór elke test een verse Pinia-instantie te activeren, om vanaf een schone state te 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)
  })
})

Zonder setActivePinia mislukt de aanroep van de store met de melding ‘”getActivePinia()” was called but there was no active Pinia. Are you trying to use a store before calling “app.use(pinia)”?’.

Wanneer je Pinia niet moet gebruiken

Een store is niet gratis: hij voegt een bestand toe, een indirectie en nog een stuk globale state. Hij verdient zichzelf terug zodra data wordt gebruikt door componenten die ver uit elkaar liggen, of zodra ze het unmounten van een component moet overleven.

Voor data die uit een realtimedienst komt, kijk je beter naar Firebase en VueFire, waarvan de bindings al reactief zijn.

Voor de lokale state van een formulier volstaat een ref. Voor state die een parent met zijn directe kind deelt, doen props en events het werk. Voor het cachen van serverdata past een specifieke tool als TanStack Query beter.

Ben je nieuw in Vue, begin dan bij de Vue 3-tutorial voor beginners: Pinia valt pas echt op zijn plek als componenten en events zitten.

Veelgemaakte fouten

De store destructureren const { total } = panier geeft een bevroren waarde. Ga voor state en getters via storeToRefs(panier), actions destructureer je wel gewoon rechtstreeks.n$reset op een setup-store | Pinia kent de beginstaat van een store in setup-vorm niet: de aanroep gooit een fout. Schrijf je eigen reset-action.
Een store aanroepen buiten een component zonder actieve Pinia Je krijgt de fout “getActivePinia was called with no active Pinia”. Roep in een test setActivePinia(createPinia()) aan voor elk geval.
Alle state van de applicatie in een store stoppen Voor een formulier volstaat een lokale ref, en voor een parent met zijn directe kind volstaan props of events.

JavaScriptPiniaVue.js

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.