Tutorial Pinia: la gestione dello stato globale in Vue 3

Tutorial Pinia: la gestione dello stato globale in Vue 3
Risposta rapida

Pinia è la libreria di gestione dello stato consigliata per Vue 3, Vuex è in manutenzione. Uno store si dichiara con defineStore, si usa chiamando usePanierStore() in qualsiasi componente e si destruttura con storeToRefs per non perdere la reattività.

Far risalire un evento da un componente al suo genitore funziona bene su uno o due livelli. Oltre, passi il tempo a riemettere eventi e a far scendere props attraverso componenti che non se ne fanno niente. Pinia risolve il problema: uno stato dichiarato una volta sola, leggibile e modificabile da qualsiasi componente.

Pinia o Vuex?

La domanda non si pone più. Vuex è in manutenzione e la documentazione ufficiale di Vue rimanda a Pinia, diventata la libreria di gestione dello stato consigliata. Su un progetto nuovo, prendi Pinia senza pensarci. Su un progetto con Vuex che funziona, la migrazione non è urgente: si fa store per store, i due convivono.

Quello che Pinia cambia in pratica: niente più mutation, solo action; niente più moduli annidati, ogni store è un modulo, e una tipizzazione che funziona senza contorsioni.

Installare

bash
npm install pinia

Poi collega il plugin all’applicazione:

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

Su un progetto nuovo ci pensa il generatore ufficiale: rispondi «sì» a Pinia durante npm create vue@latest.

Scrivere uno store

Uno store si dichiara con defineStore. Il primo argomento è un identificatore univoco, usato dai devtool. Il secondo può assumere due forme, la forma setup è quella generata dal tooling ufficiale ed è anche la più simile a un componente.

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

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

  // getter: dei 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)
  )

  // action: delle funzioni
  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 }
})

Tutto ciò che la funzione restituisce diventa pubblico. Quello che non restituisce resta privato allo store: è il modo per tenerci un contatore interno o una funzione di utilità.

La convenzione di denominazione è useQuelqueChoseStore, come per un composable.

La forma options

L’altra scrittura riprende la struttura di Vuex, senza le mutation:

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

Le due forme sono equivalenti e intercambiabili. La forma options facilita l’arrivo da Vuex, la forma setup dà più libertà, per esempio per usare un composable dentro lo store.

Usare lo store

Chiama la funzione nel componente. L’istanza è unica: due componenti che chiamano usePanierStore() ottengono esattamente lo stesso oggetto.

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>

Stato, getter e action si raggiungono direttamente sull’oggetto, senza passare da un intermediario.

storeToRefs, ovvero la reattività persa

È l’errore più frequente con Pinia. Destrutturare lo store dà valori congelati:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ numero congelato, non cambierà più
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs estrae lo stato e i getter mantenendo la reattività:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ dei ref
const { ajouter, retirer } = panier             // ✓ le action si destrutturano

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

Ricorda la regola: stato e getter passano da storeToRefs, le action si destrutturano direttamente. Le action sono semplici funzioni legate allo store, non perdono niente.

Modificare lo stato

Tre modi, in ordine di preferenza.

Una action per tutta la logica di business. È il caso normale: la regola è scritta una volta sola, nello store.

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

$patch per modificare più valori in una sola operazione, che produce una sola voce nei devtool:

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

// forma a funzione, per gli array
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

L’assegnazione diretta è consentita, al contrario di Vuex, ma tienila per i casi banali:

javascript
panier.codePromo = 'RENTREE'
$reset non esiste su uno store setup

Su uno store scritto in forma options, panier.$reset() ripristina lo stato iniziale. Su uno store in forma setup Pinia non conosce quello stato iniziale: la chiamata solleva un errore. Scrivi la tua action di azzeramento, come la funzione vider() qui sopra.

Osservare i cambiamenti

$subscribe viene chiamato a ogni mutazione dello stato. È il gancio giusto per rendere persistente il carrello:

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

Se la persistenza serve su scala più ampia, un plugin Pinia fa il lavoro su tutti gli store. Un plugin è una funzione che riceve il contesto e arricchisce ogni store:

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)

Testare uno store

Uno store si testa senza montare nessun componente. Basta attivare una nuova istanza di Pinia prima di ogni test, per ripartire da uno stato pulito:

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

Senza setActivePinia, la chiamata allo store fallisce con il messaggio « “getActivePinia()” was called but there was no active Pinia. Are you trying to use a store before calling “app.use(pinia)”? ».

Quando non usare Pinia

Uno store non è gratis: aggiunge un file, un livello di indirezione e uno stato globale in più. Si giustifica quando un dato è usato da componenti lontani tra loro, o quando deve sopravvivere allo smontaggio di un componente.

Per dati che arrivano da un servizio in tempo reale, guarda piuttosto Firebase e VueFire, i cui binding sono già reattivi.

Per lo stato locale di un form basta un ref. Per uno stato condiviso tra un genitore e il suo figlio diretto, le props e gli eventi fanno il lavoro. Per la cache dei dati del server, uno strumento dedicato come TanStack Query risponde meglio.

Se Vue è nuovo per te, parti dal tutorial Vue 3 per principianti: Pinia acquista senso una volta digeriti componenti ed eventi.

Errori frequenti

Destrutturare lo store const { total } = panier dà un valore congelato. Passa da storeToRefs(panier) per lo stato e i getter, le action, invece, si destrutturano direttamente.n$reset su uno store setup | Pinia non conosce lo stato iniziale di uno store scritto in forma setup: la chiamata solleva un errore. Scrivi la tua action di azzeramento.
Chiamare uno store fuori da un componente senza Pinia attiva Compare l'errore «getActivePinia was called with no active Pinia». In un test, chiama setActivePinia(createPinia()) prima di ogni caso.
Mettere tutto lo stato dell'applicazione in uno store Per un form basta un ref locale, e tra un genitore e il suo figlio diretto bastano le props o gli eventi.

JavaScriptPiniaVue.js

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.