
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
npm install piniaPoi collega il plugin all’applicazione:
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.
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:
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.
<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:
const panier = usePanierStore()
const { total } = panier // ✗ numero congelato, non cambierà più
panier.ajouter({ id: 1, prix: 7 })
console.log(total) // 0storeToRefs estrae lo stato e i getter mantenendo la reattività:
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) // 7Ricorda 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.
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:
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:
panier.codePromo = 'RENTREE'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:
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:
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))
})
}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:
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
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.setActivePinia(createPinia()) prima di ogni caso.

