
Pinia est la bibliothèque de gestion d’état recommandée pour Vue 3, Vuex est en maintenance. Un store se déclare avec defineStore, s’utilise en appelant usePanierStore() dans n’importe quel composant, et se déstructure avec storeToRefs pour ne pas perdre la réactivité.
Faire remonter un événement d’un composant à son parent fonctionne bien sur un ou deux niveaux. Au-delà, on passe son temps à réémettre des événements et à faire descendre des props à travers des composants qui n’en font rien. Pinia résout ce problème : un état déclaré une fois, lisible et modifiable depuis n’importe quel composant.
Pinia ou Vuex ?
La question ne se pose plus. Vuex est en maintenance et la documentation officielle de Vue renvoie vers Pinia, devenue la bibliothèque de gestion d’état recommandée. Pour un projet neuf, prenez Pinia sans hésiter. Pour un projet sous Vuex qui fonctionne, la migration n’a rien d’urgent : elle se fait store par store, les deux cohabitent.
Ce que Pinia change en pratique : plus de mutations, seulement des actions, plus de modules imbriqués, chaque store est un module, et un typage qui fonctionne sans contorsion.
Installer
npm install piniaPuis branchez le plugin sur l’application :
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')Sur un projet neuf, le générateur officiel s’en charge : répondez « oui » à Pinia lors de npm create vue@latest.
Écrire un store
Un store se déclare avec defineStore. Le premier argument est un identifiant unique, utilisé par les outils de développement. Le second peut prendre deux formes, la forme setup est celle que génère l’outillage officiel et celle qui ressemble le plus à un composant.
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const usePanierStore = defineStore('panier', () => {
// état : des ref
const lignes = ref([])
// getters : des 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 : des fonctions
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 }
})Tout ce que la fonction renvoie devient public. Ce qu’elle ne renvoie pas reste privé au store : c’est le moyen d’y garder un compteur interne ou une fonction utilitaire.
La convention de nommage est useQuelqueChoseStore, comme un composable.
La forme options
L’autre écriture reprend la structure de Vuex, sans les mutations :
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 })
}
}
})Les deux formes sont équivalentes et interchangeables. La forme options facilite l’arrivée depuis Vuex, la forme setup donne plus de liberté, notamment pour utiliser un composable à l’intérieur du store.
Utiliser le store
Appelez la fonction dans le composant. L’instance est unique : deux composants qui appellent usePanierStore() obtiennent exactement le même objet.
<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>État, getters et actions s’atteignent directement sur l’objet, sans passer par un intermédiaire.
storeToRefs, ou la réactivité perdue
C’est l’erreur la plus fréquente avec Pinia. Déstructurer le store donne des valeurs figées :
const panier = usePanierStore()
const { total } = panier // ✗ nombre figé, ne changera plus
panier.ajouter({ id: 1, prix: 7 })
console.log(total) // 0storeToRefs extrait l’état et les getters en conservant la réactivité :
import { storeToRefs } from 'pinia'
const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier) // ✓ des ref
const { ajouter, retirer } = panier // ✓ les actions se déstructurent
panier.ajouter({ id: 1, prix: 7 })
console.log(total.value) // 7Retenez la règle : l’état et les getters passent par storeToRefs, les actions se déstructurent directement. Les actions sont de simples fonctions liées au store, elles ne perdent rien.
Modifier l’état
Trois façons, par ordre de préférence.
Une action pour toute logique métier. C’est le cas normal : la règle est écrite une fois, dans le store.
panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })$patch pour modifier plusieurs entrées en une seule opération, ce qui ne produit qu’une entrée dans les outils de développement :
panier.$patch({ lignes: [], codePromo: null })
// forme fonction, pour les tableaux
panier.$patch((state) => {
state.lignes.push(nouvelleLigne)
state.derniereMaj = Date.now()
})L’affectation directe est permise, contrairement à Vuex, mais réservez-la aux cas triviaux :
panier.codePromo = 'RENTREE'Sur un store écrit en forme options, panier.$reset() remet l’état initial. Sur un store en forme setup, Pinia ne connaît pas cet état initial : l’appel lève une erreur. Écrivez votre propre action de remise à zéro, comme la fonction vider() ci-dessus.
Observer les changements
$subscribe est appelé à chaque mutation de l’état. C’est le crochet pour persister le panier :
panier.$subscribe((mutation, state) => {
localStorage.setItem('panier', JSON.stringify(state.lignes))
})Pour un besoin de persistance plus large, un plugin Pinia fait le travail sur tous les stores. Un plugin est une fonction qui reçoit le contexte et enrichit chaque 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)Tester un store
Un store se teste sans monter de composant. Il faut seulement activer une instance Pinia neuve avant chaque test, pour repartir d’un état vierge :
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)
})
})Sans setActivePinia, l’appel du store échoue avec le message « « getActivePinia() » was called but there was no active Pinia. Are you trying to use a store before calling « app.use(pinia) »? ».
Quand ne pas utiliser Pinia
Un store n’est pas gratuit : il ajoute un fichier, une indirection et un état global de plus. Il se justifie quand une donnée est utilisée par des composants éloignés, ou qu’elle doit survivre au démontage d’un composant.
Pour des données qui viennent d’un service temps réel, voyez plutôt Firebase et VueFire, dont les liaisons sont déjà réactives.
Pour l’état local d’un formulaire, un ref suffit. Pour un état partagé entre un parent et son enfant direct, les props et les événements font le travail. Pour du cache de données serveur, un outil dédié comme TanStack Query répond mieux au besoin.
Si vous découvrez Vue, commencez par le tutoriel Vue 3 pour les débutants : Pinia prend tout son sens une fois les composants et les événements acquis.
Erreurs fréquentes
const { total } = panier donne une valeur figée. Passez par storeToRefs(panier) pour l’état et les getters, les actions, elles, se déstructurent directement.n$reset sur un store setup | Pinia ne connaît pas l’état initial d’un store écrit en forme setup : l’appel lève une erreur. Écrivez votre propre action de remise à zéro.setActivePinia(createPinia()) avant chaque cas.

