Tutoriel Pinia : la gestion d’état globale dans Vue 3

Tutoriel Pinia : la gestion d’état globale dans Vue 3
Réponse rapide

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

bash
npm install pinia

Puis branchez le plugin sur l’application :

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

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.

src/stores/panier.js
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 :

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

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.

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>

É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 :

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ nombre figé, ne changera plus
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs extrait l’état et les getters en conservant la réactivité :

javascript
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)                        // 7

Retenez 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.

javascript
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 :

javascript
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 :

javascript
panier.codePromo = 'RENTREE'
$reset n’existe pas sur un store setup

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 :

javascript
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 :

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)

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 :

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

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

Déstructurer le store 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.
Appeler un store hors composant sans Pinia active L’erreur « getActivePinia was called with no active Pinia » apparaît. Dans un test, appelez setActivePinia(createPinia()) avant chaque cas.
Mettre tout l’état de l’application dans un store Un ref local suffit pour un formulaire, et les props ou les événements pour un parent et son enfant direct.

JavaScriptPiniaVue.js

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.