Développement Web

Pinia : gérer l’état partagé dans Vue 3

13 دقائق للقراءة
📍 Article principal : Vue 3 et la Composition API : le guide complet. Pour la vue d’ensemble du parcours, commencez par là.

Quand les données ne savent plus où habiter

NoteFlux grandit. La liste des notes est dans App.vue, mais la barre latérale veut afficher le nombre de favoris, l’en-tête veut un bouton « tout marquer comme lu », et bientôt une page de détail aura besoin de la même note. Faire transiter ces données de parent en enfant, puis les faire remonter par des événements sur cinq niveaux, devient vite ingérable — on appelle ça le « tunnel de props ». La donnée n’a plus de domicile clair.

La réponse de Vue est Pinia, la bibliothèque officielle de gestion d’état. L’idée : sortir l’état partagé des composants et le ranger dans un store, un objet central que n’importe quel composant peut lire et modifier directement, sans tunnel. Dans ce tutoriel, vous déplacez toute la logique des notes de NoteFlux dans un store Pinia, avec sa persistance dans le navigateur.

Ce que vous allez apprendre

  • Installer et brancher Pinia dans une application Vue 3.
  • Définir un store avec son état, ses getters et ses actions.
  • Lire et modifier le store depuis n’importe quel composant.
  • Déstructurer un store sans perdre la réactivité grâce à storeToRefs.
  • Persister l’état dans le stockage local pour le retrouver au rechargement.

Ce que vous allez construire

Un store useNotesStore qui détient le tableau des notes, le terme de recherche et le filtre favoris ; expose une liste filtrée et un compteur de favoris en getters ; et propose des actions pour ajouter, supprimer et basculer le statut favori. Tous les composants de NoteFlux liront ce store unique — fini les props qui se promènent.

Prérequis

  • Un projet Vue 3 fonctionnel avec quelques composants (voir les tutoriels réactivité et composants).
  • Comprendre ref, computed et la notion de props/events.
  • ⏱️ Temps estimé : ~45 minutes. Au moment d’écrire, Pinia est en version 3, qui requiert Vue 3.

Étape 1 — Installer et brancher Pinia

Pinia s’ajoute comme une dépendance, puis se branche sur l’application au démarrage. Si vous avez coché Pinia lors de npm create vue@latest, tout est déjà en place ; sinon, on l’installe à la main. L’installation crée l’instance Pinia et l’enregistre comme plugin de l’application.

npm install pinia
// 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')

La ligne app.use(createPinia()) rend tous les stores accessibles partout dans l’application. C’est la seule configuration globale nécessaire ; le reste se passe store par store.

Point d’étape — L’application doit toujours démarrer sans erreur après npm run dev. Si la console affiche « getActivePinia was called with no active Pinia », c’est que le app.use(createPinia()) manque ou arrive après le montage.

Étape 2 — Définir le store des notes

Un store se déclare avec defineStore, en lui donnant un identifiant unique. Pinia propose deux syntaxes. La première, dite « par options », ressemble à l’Options API : un objet avec state (une fonction qui renvoie l’état initial), getters (les valeurs dérivées) et actions (les fonctions qui modifient l’état). Créons src/stores/notes.js :

// src/stores/notes.js
import { defineStore } from 'pinia'

export const useNotesStore = defineStore('notes', {
  state: () => ({
    notes: [
      { id: 1, titre: 'Facture client Diop', favori: true },
      { id: 2, titre: 'Idées page d\'accueil', favori: false }
    ],
    recherche: '',
    afficherFavoris: false
  }),
  getters: {
    notesFiltrees(state) {
      const terme = state.recherche.trim().toLowerCase()
      let liste = state.afficherFavoris ? state.notes.filter(n => n.favori) : state.notes
      return terme ? liste.filter(n => n.titre.toLowerCase().includes(terme)) : liste
    },
    nbFavoris: (state) => state.notes.filter(n => n.favori).length
  },
  actions: {
    ajouter(titre) {
      this.notes.push({ id: Date.now(), titre, favori: false })
    },
    supprimer(id) {
      this.notes = this.notes.filter(n => n.id !== id)
    },
    basculerFavori(id) {
      const note = this.notes.find(n => n.id === id)
      if (note) note.favori = !note.favori
    }
  }
})

Remarquez la structure. state est une fonction — c’est important pour que chaque utilisation parte d’un état frais, notamment en rendu serveur. Les getters reçoivent l’état en argument et fonctionnent comme des computed : mis en cache, recalculés à la demande. Les actions sont des méthodes ordinaires où this désigne le store : elles peuvent muter l’état directement, et c’est l’endroit où l’on place toute la logique métier, y compris asynchrone (appels API).

Point d’étape — Le fichier ne produit encore rien à l’écran, mais il ne doit lever aucune erreur d’import. Vérifiez que state est bien une fonction fléchée qui renvoie un objet (les parenthèses autour des accolades).

Étape 3 — Utiliser le store dans un composant

Dans n’importe quel composant, on appelle la fonction du store pour obtenir l’instance partagée. On accède ensuite à l’état et aux getters comme à des propriétés, et on déclenche les actions comme des méthodes :

<script setup>
import { useNotesStore } from '@/stores/notes'

const store = useNotesStore()
</script>

<template>
  <input v-model="store.recherche" placeholder="Rechercher">
  <p>{{ store.notesFiltrees.length }} note(s) — {{ store.nbFavoris }} favori(s)</p>
  <ul>
    <li v-for="note in store.notesFiltrees" :key="note.id">
      {{ note.titre }}
      <button @click="store.basculerFavori(note.id)">{{ note.favori ? '★' : '☆' }}</button>
      <button @click="store.supprimer(note.id)">Supprimer</button>
    </li>
  </ul>
</template>

C’est tout. Le même store peut être appelé dans la barre latérale, l’en-tête ou la page de détail : ils partagent automatiquement la même instance et les mêmes données. Modifiez une note dans un composant, et tous les autres se mettent à jour. Le « tunnel de props » a disparu, remplacé par un point d’accès unique et clair.

Point d’étape — La liste, la recherche et les boutons doivent fonctionner exactement comme avant, mais sans aucune prop. Ouvrez l’onglet Pinia de Vue DevTools : vous voyez l’état du store en direct et chaque action qui se déclenche.

Étape 4 — Déstructurer proprement avec storeToRefs

Écrire store.recherche partout est verbeux. La tentation est de déstructurer : const { recherche, notesFiltrees } = store. Mauvaise idée — comme pour un objet reactive, cela rompt la réactivité, et vos valeurs se figent. La solution officielle est storeToRefs, qui extrait l’état et les getters en ref réactifs :

import { storeToRefs } from 'pinia'
import { useNotesStore } from '@/stores/notes'

const store = useNotesStore()
const { recherche, notesFiltrees, nbFavoris } = storeToRefs(store)
// les actions, elles, se deconstruisent directement :
const { ajouter, supprimer, basculerFavori } = store

Règle simple à mémoriser : storeToRefs pour l’état et les getters ; déstructuration directe pour les actions. Les actions sont des fonctions liées au store, elles ne perdent pas leur lien ; l’état et les getters, eux, ont besoin de rester réactifs. Une fois cela fait, on écrit recherche et notesFiltrees directement dans le gabarit, sans le préfixe store..

Point d’étape — Remplacez les store.x par les variables déstructurées et vérifiez que la recherche réagit toujours. Si l’écran fige, c’est que vous avez déstructuré l’état sans storeToRefs.

Étape 5 — Persister l’état dans le navigateur

Pour que les notes survivent à un rechargement, on les enregistre dans le stockage local. Pinia expose $subscribe, qui exécute une fonction à chaque changement de l’état — l’endroit idéal pour sauvegarder. On ajoute aussi une action de chargement appelée au démarrage :

// dans un composant racine, apres avoir obtenu le store
const store = useNotesStore()

// charger l'etat sauvegarde au demarrage
const sauvegarde = localStorage.getItem('noteflux-notes')
if (sauvegarde) store.notes = JSON.parse(sauvegarde)

// sauvegarder a chaque changement
store.$subscribe((mutation, state) => {
  localStorage.setItem('noteflux-notes', JSON.stringify(state.notes))
})

À chaque ajout, suppression ou bascule de favori, l’état est sérialisé et stocké. Au prochain chargement, les notes reviennent telles quelles. Pour un projet réel, l’écosystème propose un plugin dédié, pinia-plugin-persistedstate, qui automatise tout cela par une simple option persist: true sur le store ; le faire à la main une fois, comme ici, permet de comprendre ce que le plugin fait pour vous.

Point d’étape — Ajoutez une note, rechargez la page : la note doit toujours être là. Inspectez localStorage dans l’onglet Application des outils du navigateur pour voir la clé noteflux-notes.

Trois façons de modifier l’état

Au-delà des actions, Pinia offre plusieurs leviers pour agir sur le store, chacun avec son usage. Le plus direct : muter une propriété (store.recherche = 'facture'), parfait pour un changement isolé. Quand plusieurs champs changent ensemble, $patch applique tout en une seule opération, ce qui est plus performant et n’émet qu’une notification de changement :

// modifier plusieurs champs d'un coup
store.$patch({ recherche: '', afficherFavoris: true })

// ou avec une fonction, pour une logique plus riche
store.$patch((state) => {
  state.notes.push({ id: Date.now(), titre: 'Note rapide', favori: false })
  state.recherche = ''
})

Enfin, pour repartir d’un état vierge, les stores écrits en syntaxe par options disposent de store.$reset(), qui rétablit l’état initial défini dans state. C’est précieux pour un bouton « réinitialiser les filtres » ou à la déconnexion d’un utilisateur. Ces trois mécanismes — mutation directe, $patch groupé, $reset — couvrent tous les besoins courants ; les actions, elles, restent l’endroit où encapsuler la logique métier réutilisable plutôt que de disperser des mutations dans les composants.

La syntaxe « setup » en alternative

Pinia accepte une seconde façon d’écrire un store, plus proche de <script setup> : on passe une fonction qui déclare des ref (l’état), des computed (les getters) et des fonctions (les actions), puis on retourne ce qu’on veut exposer. Beaucoup la préfèrent pour sa cohérence avec le reste du code.

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useNotesStore = defineStore('notes', () => {
  const notes = ref([])
  const recherche = ref('')
  const nbFavoris = computed(() => notes.value.filter(n => n.favori).length)
  function ajouter(titre) {
    notes.value.push({ id: Date.now(), titre, favori: false })
  }
  return { notes, recherche, nbFavoris, ajouter }
})

Les deux syntaxes sont équivalentes et interchangeables. La syntaxe par options est très lisible pour les débutants ; la syntaxe setup brille quand la logique se complexifie ou qu’on veut réutiliser des composables. Une seule réserve : avec la syntaxe setup, $reset() n’est pas fourni d’office, il faut l’écrire soi-même.

Pièges fréquents

Symptôme Cause probable Correctif
« getActivePinia was called with no active Pinia » createPinia() non enregistré, ou store appelé trop tôt Appeler app.use(createPinia()) avant mount ; appeler le store dans setup
Les valeurs déstructurées ne réagissent plus Déstructuration directe de l’état/getters Passer par storeToRefs(store)
state partagé entre instances / fuite en SSR state écrit comme objet et non comme fonction Toujours state: () => ({...})
this indéfini dans une action Action écrite en fonction fléchée Utiliser une fonction classique pour garder this
$reset() indisponible Store écrit en syntaxe setup Écrire une action de remise à zéro manuelle

Réalités du terrain

Centraliser l’état apporte un bénéfice concret quand la connexion est mauvaise : la persistance locale fait office de cache. En sauvegardant le store dans le stockage local, NoteFlux affiche immédiatement les dernières notes connues au démarrage, avant même qu’une éventuelle synchronisation réseau n’aboutisse — l’application reste utilisable hors ligne ou sur une liaison intermittente. C’est aussi dans les actions qu’on place la logique de reprise sur erreur : si un appel d’enregistrement échoue, on conserve la modification localement et on la rejoue plus tard. Pinia reste par ailleurs très léger, ce qui ne pèse pas sur le poids du bundle envoyé aux appareils modestes.

Récapitulatif

Vous avez sorti l’état partagé de NoteFlux des composants pour le ranger dans un store Pinia : l’état dans state, les valeurs dérivées dans getters, la logique dans actions. N’importe quel composant lit et modifie ce store unique, sans tunnel de props. Vous savez le déstructurer proprement avec storeToRefs et le persister dans le navigateur. C’est l’outil qui fait passer une application Vue du prototype à l’échelle réelle.

Aide-mémoire

Élément Rôle
createPinia() + app.use() Activer Pinia dans l’application
defineStore('id', {...}) Définir un store (syntaxe par options)
state: () => ({...}) État initial — toujours une fonction
getters Valeurs dérivées, mises en cache
actions Logique métier ; mutent l’état via this
storeToRefs(store) Déstructurer état/getters en gardant la réactivité
store.$patch() / $subscribe() Modifier en lot / réagir à chaque changement

À vous de jouer

Ajoutez au store un getter noteParId qui renvoie une fonction prenant un identifiant et retournant la note correspondante (utile pour la future page de détail). Ajoutez aussi une action renommer(id, titre).

Voir une solution
getters: {
  // un getter qui renvoie une fonction = getter parametre
  noteParId: (state) => (id) => state.notes.find(n => n.id === id)
},
actions: {
  renommer(id, titre) {
    const note = this.notes.find(n => n.id === id)
    if (note) note.titre = titre
  }
}

Tutoriels frères

Pour aller plus loin

FAQ

Pinia ou Vuex ?
Pinia est la recommandation officielle actuelle pour Vue 3. Plus léger, mieux typé et sans la verbosité des mutations de Vuex, il est devenu le standard. Les nouveaux projets n’ont aucune raison de partir sur Vuex.

Quand utiliser un store plutôt que des props ?
Quand une donnée doit être lue ou modifiée par des composants éloignés dans l’arbre, ou survivre à la navigation entre pages. Pour un échange local parent-enfant, les props et les events restent plus simples et préférables.

Peut-on avoir plusieurs stores ?
Oui, et c’est même conseillé : un store par domaine (notes, utilisateur, réglages). Un store peut en appeler un autre en l’important, ce qui permet de composer la logique sans tout entasser au même endroit.

Les actions peuvent-elles être asynchrones ?
Oui. Une action peut être async et contenir des await (appels API). C’est l’endroit recommandé pour toute la logique réseau, ce qui garde les composants concentrés sur l’affichage.

Où appeler useNotesStore() exactement ?
Dans le corps de <script setup> d’un composant, ou à l’intérieur d’une autre fonction de store. L’appeler au niveau d’un module, hors de tout composant, peut se produire avant que Pinia ne soit activé et déclenche l’erreur « no active Pinia ». La règle : appelez le store là où vous en avez besoin, pas au sommet d’un fichier importé tôt.

Comment déboguer un store ?
L’extension Vue DevTools possède un onglet Pinia dédié : il affiche l’état en direct, l’historique des actions déclenchées, et permet même de modifier l’état à la main pour tester. C’est l’outil le plus rapide pour comprendre pourquoi une donnée ne se met pas à jour comme prévu.

مشاركة