Maîtrisez Vue.js : Créez des Interfaces Utilisateur Réactives et Performantes
Maîtrisez Vue.js : Créez des Interfaces Utilisateur Réactives et Performantes

Gestion de l'État avec Vuex/Pinia

Bienvenue dans ce module de notre cours "Maîtrisez Vue.js : Créez des Interfaces Utilisateur Réactives et Performantes". Aujourd'hui, nous allons aborder un sujet fondamental pour le développement d'applications Vue.js d'envergure : la gestion de l'état global.

Introduction à la Gestion de l'État

Lorsqu'on développe des applications web interactives avec des frameworks comme Vue.js, les données circulent et sont manipulées par différents composants. Dans les applications simples, la gestion de l'état (les données de l'application) peut se faire localement au sein des composants via data et la communication parente-enfant (props et emit). Cependant, cette approche devient rapidement complexe et ingérable pour les applications plus grandes où :

  • Plusieurs composants ont besoin d'accéder aux mêmes données.
  • Plusieurs composants ont besoin de modifier les mêmes données.
  • Les données doivent être persistantes entre différentes vues ou sessions.
  • La logique métier devient complexe et doit être centralisée.

Sans une stratégie claire, on se retrouve avec des problèmes tels que le "prop drilling" (passer des props à travers de nombreux niveaux de composants), des événements complexes à suivre, et une difficulté générale à comprendre d'où vient une donnée ou pourquoi elle a changé.

C'est là qu'interviennent les bibliothèques de gestion d'état centralisées comme Vuex et Pinia. Elles offrent une "source de vérité unique" pour l'état de votre application, rendant les changements d'état prévisibles et faciles à déboguer.

Qu'est-ce que l'État Global ?

L'état global fait référence à l'ensemble des données qui sont pertinentes pour l'ensemble de votre application, ou pour une grande partie de celle-ci, et qui doivent être accessibles et modifiables de manière cohérente par n'importe quel composant, quel que soit son emplacement dans l'arborescence des composants.

Imaginez un utilisateur connecté, son panier d'achat, les paramètres de thème de l'application, ou les données d'une liste de produits. Ces informations sont souvent nécessaires à plusieurs endroits et constituent des candidats idéaux pour être gérées dans un store global.

Vuex et Pinia : Deux Approches pour le Même But

Historiquement, Vuex a été la bibliothèque officielle de gestion d'état pour Vue.js, fournissant un modèle robuste et éprouvé. Cependant, avec l'avènement de Vue 3 et l'API de composition, une nouvelle solution plus légère et plus intuitive est apparue : Pinia. Pinia est désormais la bibliothèque recommandée par l'équipe de Vue.js pour la gestion d'état dans les nouvelles applications Vue 3.

Nous allons explorer les deux, en mettant l'accent sur leurs concepts fondamentaux et leur utilisation pratique.

Vuex : Le Gardien Historique de l'État

Vuex est une bibliothèque de gestion d'état pour Vue.js inspirée par les principes de Flux et Redux. Elle fournit un conteneur centralisé pour l'état de toutes les parties de votre application.

Concepts Fondamentaux de Vuex

Vuex repose sur un ensemble de concepts stricts pour garantir une gestion prévisible de l'état :

  • State (État) : C'est l'objet qui contient toutes les données de votre application. C'est la "source de vérité unique". L'état dans Vuex est réactif, ce qui signifie que lorsque l'état change, tous les composants qui l'utilisent sont automatiquement mis à jour.

    // store/index.js
    const store = createStore({
      state() {
        return {
          count: 0,
          user: null
        }
      },
      // ...
    })
    
  • Getters : Ce sont des fonctions qui permettent de dériver un état calculé à partir de l'état du store. Similaires aux propriétés calculées des composants, ils sont mis en cache et ne sont recalculés que lorsque leurs dépendances changent.

    // store/index.js
    const store = createStore({
      // ...
      getters: {
        doubleCount(state) {
          return state.count * 2
        },
        isAuthenticated(state) {
          return !!state.user
        }
      },
      // ...
    })
    
  • Mutations : Ce sont les seules façons de modifier directement l'état de Vuex. Les mutations sont toujours des fonctions synchrones. Elles reçoivent l'état comme premier argument. Il est crucial que les mutations soient synchrones pour permettre l'inspection et le traçage de l'état via les Vue Devtools.

    // store/index.js
    const store = createStore({
      // ...
      mutations: {
        increment(state) {
          state.count++
        },
        setCount(state, payload) {
          state.count = payload
        }
      },
      // ...
    })
    

    Pour appeler une mutation, on utilise store.commit('mutationName', payload).

  • Actions : Elles contiennent la logique métier, souvent asynchrone. Les actions ne modifient jamais l'état directement ; elles appellent des mutations pour le faire. Les actions peuvent contenir des appels API, des logiques conditionnelles complexes, etc.

    // store/index.js
    const store = createStore({
      // ...
      actions: {
        async incrementAsync({ commit }) {
          // Simule un appel API
          await new Promise(resolve => setTimeout(resolve, 1000));
          commit('increment');
        },
        async fetchUser({ commit }, userId) {
            try {
                const response = await fetch(`/api/users/${userId}`);
                const user = await response.json();
                commit('setUser', user); // Assumant une mutation 'setUser' existe
            } catch (error) {
                console.error("Failed to fetch user:", error);
            }
        }
      },
      // ...
    })
    

    Pour déclencher une action, on utilise store.dispatch('actionName', payload).

  • Modules : Pour les applications de grande taille, le store Vuex peut devenir très volumineux. Les modules permettent de diviser le store en sous-modules indépendants, chacun ayant son propre état, ses getters, ses mutations et ses actions.

    // store/modules/cart.js
    const cartModule = {
      namespaced: true, // Important pour éviter les conflits de noms
      state: () => ({
        items: []
      }),
      mutations: { /* ... */ },
      actions: { /* ... */ },
      getters: { /* ... */ }
    }
    
    // store/index.js
    import { createStore } from 'vuex'
    import cartModule from './modules/cart'
    
    const store = createStore({
      // ...
      modules: {
        cart: cartModule
      }
    })
    

Exemple Pratique avec Vuex : Un Compteur Simple

Implémentons un store Vuex pour gérer un compteur.

1. Installation de Vuex

npm install vuex@next # Pour Vue 3

2. Création du Store (src/store/index.js)

// src/store/index.js
import { createStore } from 'vuex';

const store = createStore({
  // L'état de l'application
  state() {
    return {
      count: 0
    };
  },
  // Getters pour obtenir des données dérivées de l'état
  getters: {
    doubleCount(state) {
      return state.count * 2;
    }
  },
  // Mutations pour modifier l'état (toujours synchrones)
  mutations: {
    increment(state) {
      state.count++;
    },
    decrement(state) {
      state.count--;
    },
    incrementBy(state, payload) {
      state.count += payload;
    }
  },
  // Actions pour les opérations asynchrones ou la logique métier, qui appellent des mutations
  actions: {
    async incrementAsync({ commit }) {
      // Simule une opération asynchrone, par exemple un appel API
      await new Promise(resolve => setTimeout(resolve, 1000));
      commit('increment');
    },
    async incrementByAsync({ commit }, amount) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      commit('incrementBy', amount);
    }
  }
});

export default store;

3. Intégration du Store dans l'Application (src/main.js)

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import store from './store'; // Importe votre store Vuex

const app = createApp(App);
app.use(store); // Indique à l'application d'utiliser le store Vuex
app.mount('#app');

4. Utilisation du Store dans un Composant (src/components/CounterVuex.vue)

<!-- src/components/CounterVuex.vue -->
<template>
  <div class="vuex-counter">
    <h3>Compteur Vuex</h3>
    <p>Compteur : {{ count }}</p>
    <p>Compteur doublé (getter) : {{ doubleCount }}</p>
    <button @click="increment">Incrémenter</button>
    <button @click="decrement">Décrémenter</button>
    <button @click="incrementBy(5)">Incrémenter de 5</button>
    <button @click="incrementAsync">Incrémenter (async)</button>
    <button @click="incrementByAsync(10)">Incrémenter de 10 (async)</button>
  </div>
</template>

<script>
// Pour l'Options API
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';

export default {
  name: 'CounterVuex',
  computed: {
    // Permet de mapper les propriétés de l'état du store aux propriétés calculées du composant
    ...mapState(['count']),
    // Permet de mapper les getters du store aux propriétés calculées du composant
    ...mapGetters(['doubleCount'])
  },
  methods: {
    // Permet de mapper les mutations du store aux méthodes du composant
    ...mapMutations(['increment', 'decrement', 'incrementBy']),
    // Permet de mapper les actions du store aux méthodes du composant
    ...mapActions(['incrementAsync', 'incrementByAsync'])
  }
};

// Pour la Composition API (alternative, souvent préférée avec Vue 3)
/*
import { computed } from 'vue';
import { useStore } from 'vuex';

export default {
  name: 'CounterVuex',
  setup() {
    const store = useStore();

    const count = computed(() => store.state.count);
    const doubleCount = computed(() => store.getters.doubleCount);

    const increment = () => store.commit('increment');
    const decrement = () => store.commit('decrement');
    const incrementBy = (amount) => store.commit('incrementBy', amount);
    const incrementAsync = () => store.dispatch('incrementAsync');
    const incrementByAsync = (amount) => store.dispatch('incrementByAsync', amount);

    return {
      count,
      doubleCount,
      increment,
      decrement,
      incrementBy,
      incrementAsync,
      incrementByAsync
    };
  }
};
*/
</script>

<style scoped>
.vuex-counter {
  border: 1px solid #42b983;
  padding: 15px;
  margin-top: 20px;
  border-radius: 8px;
  background-color: #e6ffee;
}
button {
  margin: 5px;
  padding: 8px 12px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background-color: #368e6b;
}
</style>

Ce code montre comment le state (count) est accédé, comment un getter (doubleCount) est utilisé pour une valeur dérivée, et comment les mutations et actions sont déclenchées pour modifier l'état de manière contrôlée.

Pinia : La Nouvelle Génération de Gestion d'État

Pinia est la nouvelle bibliothèque de gestion d'état recommandée pour Vue 3. Elle a été conçue pour être plus simple, plus légère et offrir une meilleure prise en charge de TypeScript que Vuex. Pinia tire pleinement parti de l'API de Composition de Vue 3.

Pourquoi Pinia ?

  • Simplicité : Moins de concepts à apprendre. Pas de mutations.
  • API Intuitive : Utilise la fonction defineStore qui renvoie une fonction useStore, très naturelle avec la Composition API.
  • Typage Robuste : Excellente inférence de type avec TypeScript.
  • Légèreté : Moins de 1KB de bundle compressé.
  • Modularité Intégrée : Chaque store Pinia est un module indépendant par nature.
  • Performance : Les stores sont générés à la demande et peuvent être divisés en code.
  • Devtools Améliorés : Une meilleure expérience de débogage dans les Vue Devtools.

Concepts Fondamentaux de Pinia

Pinia simplifie grandement les choses par rapport à Vuex :

  • State : Similaire à Vuex, c'est l'objet qui contient les données réactives de votre store. Il est défini comme une fonction renvoyant l'état initial.

    // stores/counter.js
    import { defineStore } from 'pinia';
    
    export const useCounterStore = defineStore('counter', {
      state: () => ({
        count: 0
      }),
      // ...
    });
    
  • Getters : Identiques aux getters de Vuex. Ce sont des fonctions calculées sur l'état, avec mise en cache.

    // stores/counter.js
    export const useCounterStore = defineStore('counter', {
      // ...
      getters: {
        doubleCount(state) {
          return state.count * 2;
        },
        doubleCountPlusOne: (state) => state.count * 2 + 1,
        // Les getters peuvent aussi accéder à d'autres getters via `this`
        quadrupleCount() {
          return this.doubleCount * 2;
        }
      },
      // ...
    });
    
  • Actions : C'est là que Pinia diffère le plus de Vuex. Dans Pinia, les actions peuvent directement modifier l'état. Elles peuvent être synchrones ou asynchrones. Elles agissent comme des "méthodes" de votre store.

    // stores/counter.js
    export const useCounterStore = defineStore('counter', {
      // ...
      actions: {
        increment() {
          this.count++;
        },
        async incrementAsync() {
          await new Promise(resolve => setTimeout(resolve, 1000));
          this.count++; // Modification directe de l'état
        },
        setCount(amount) {
          this.count = amount;
        }
      }
    });
    

    Il n'y a plus de distinction mutations vs actions. Tout est une action (ou une méthode) dans Pinia.

Exemple Pratique avec Pinia : Le Même Compteur Simple

Reprenons notre exemple de compteur avec Pinia.

1. Installation de Pinia

npm install pinia

2. Création du Store (src/stores/counter.js)

Notez la convention de nommage use...Store.

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

// Le premier argument est l'ID unique du store, utilisé par les Devtools
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubleCount(state) {
      return state.count * 2;
    }
  },
  actions: {
    increment() {
      this.count++; // Accès direct à l'état via `this`
    },
    decrement() {
      this.count--;
    },
    incrementBy(amount) {
      this.count += amount;
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000));
      this.count++;
    },
    async incrementByAsync(amount) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      this.count += amount;
    }
  }
});

3. Intégration du Store dans l'Application (src/main.js)

// src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia'; // Importe Pinia
import App from './App.vue';

const app = createApp(App);
const pinia = createPinia(); // Crée une instance Pinia

app.use(pinia); // Indique à l'application d'utiliser Pinia
app.mount('#app');

4. Utilisation du Store dans un Composant (src/components/CounterPinia.vue)

<!-- src/components/CounterPinia.vue -->
<template>
  <div class="pinia-counter">
    <h3>Compteur Pinia</h3>
    <p>Compteur : {{ count }}</p>
    <p>Compteur doublé (getter) : {{ doubleCount }}</p>
    <button @click="increment">Incrémenter</button>
    <button @click="decrement">Décrémenter</button>
    <button @click="incrementBy(5)">Incrémenter de 5</button>
    <button @click="incrementAsync">Incrémenter (async)</button>
    <button @click="incrementByAsync(10)">Incrémenter de 10 (async)</button>
  </div>
</template>

<script setup>
// Utilisation de la Composition API avec <script setup> pour Pinia, très courant
import { computed } from 'vue';
import { useCounterStore } from '../stores/counter';

const counterStore = useCounterStore();

// Accès direct à l'état via counterStore.count (réactif)
const count = computed(() => counterStore.count);

// Accès aux getters via counterStore.doubleCount (réactif)
const doubleCount = computed(() => counterStore.doubleCount);

// Appel des actions via counterStore.actionName()
const increment = () => counterStore.increment();
const decrement = () => counterStore.decrement();
const incrementBy = (amount) => counterStore.incrementBy(amount);
const incrementAsync = () => counterStore.incrementAsync();
const incrementByAsync = (amount) => counterStore.incrementByAsync(amount);

// Alternativement, pour les propriétés de l'état et les getters, vous pouvez utiliser `storeToRefs`
// qui déconstruit les propriétés réactives du store pour éviter la perte de réactivité.
/*
import { storeToRefs } from 'pinia';
const { count, doubleCount } = storeToRefs(counterStore);
// Pour les actions, il n'est pas nécessaire d'utiliser storeToRefs car ce sont des fonctions
const { increment, decrement, incrementBy, incrementAsync, incrementByAsync } = counterStore;
*/
</script>

<style scoped>
.pinia-counter {
  border: 1px solid #007bff;
  padding: 15px;
  margin-top: 20px;
  border-radius: 8px;
  background-color: #e6f2ff;
}
button {
  margin: 5px;
  padding: 8px 12px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background-color: #0056b3;
}
</style>

Avec Pinia, l'approche est plus directe : l'état est accessible et les actions sont appelées comme de simples méthodes sur l'instance du store obtenue via useCounterStore(). L'utilisation de computed pour l'état direct (counterStore.count) est importante pour maintenir la réactivité si vous déstructurez l'objet counterStore lui-même (ex: const { count } = counterStore;). Si vous utilisez storeToRefs de Pinia, cela n'est pas nécessaire.

Vuex vs. Pinia : Comparaison Détaillée

| Caractéristique | Vuex | Pinia | | :-------------------- | :------------------------------------------------------ | :-------------------------------------------------------------- | | Philosophie | Strict (mutations synchrones) | Plus libre (actions peuvent modifier l'état directement) | | Mutations | Obligatoires pour modifier l'état, doivent être synchrones. | N'existent pas. Les actions modifient l'état directement. | | Actions | Asynchrones, commit des mutations. | Synchrones ou asynchrones, peuvent modifier l'état directement. | | Modules | Concept explicite (modules option, namespaced). | Chaque store est un module par défaut. Modularité naturelle. | | API | Options API (mapState, mapGetters, etc.) et Composition API (useStore). | Conçue pour la Composition API (defineStore, useStore). Compatible Options API. | | TypeScript | Support présent, mais peut être verbeux pour un typage complet. | Excellent support et inférence de type dès la conception. | | Taille du bundle | Plus grand. | Plus petit (environ 1KB gzippé). | | Historique | Bibliothèque officielle pour Vue 2, compatible Vue 3. | La nouvelle bibliothèque recommandée pour Vue 3. | | Initialisation | L'état est défini via une propriété state directe. | L'état est une fonction qui retourne l'objet d'état. | | Devtools | Bon support, permet de suivre les mutations. | Excellent support, meilleure expérience pour les actions. |

Quand Choisir ?

  • Pour les nouvelles applications Vue 3 : Pinia est le choix recommandé. Sa simplicité, son support TypeScript, et sa légèreté en font une option supérieure.
  • Pour les applications Vue 2 existantes : Si vous ne migrez pas vers Vue 3, Vuex reste la norme. Si vous migrez une grosse application Vue 2 vers Vue 3, Vuex est également une option valide si vous souhaitez minimiser les changements.
  • Pour une complexité modérée à élevée : Les deux solutions sont adaptées. Pinia rend la gestion des états complexes plus agréable grâce à son API simplifiée.

Bonnes Pratiques de Gestion d'État

Quelle que soit la solution choisie, quelques bonnes pratiques sont essentielles :

  • Définissez clairement votre état : Qu'est-ce qui doit être global ? Qu'est-ce qui peut rester local au composant ? Évitez de tout mettre dans le store global si ce n'est pas nécessaire. L'état local est souvent suffisant pour des composants isolés.
  • Nommage cohérent : Utilisez des conventions de nommage claires pour vos getters, mutations, actions et modules/stores.
  • Atomicité des mutations/actions : Chaque mutation/action devrait avoir une responsabilité unique et bien définie.
  • Modularisation : Divisez votre store en modules (Vuex) ou en stores indépendants (Pinia) par fonctionnalité (ex: user, cart, products, settings). Cela améliore la maintenabilité et la lisibilité.
  • Utilisez les Devtools : Les Vue Devtools sont incroyablement puissants pour déboguer les applications Vue.js. Ils vous permettent de visualiser l'état de votre store, de suivre les mutations et les actions, et même de "remonter le temps" pour voir l'état à différents moments.
  • Évitez les effets de bord directs sur l'état : Même si Pinia le permet dans les actions, il est toujours une bonne pratique de s'assurer que les changements d'état sont intentionnels et traçables.
  • Gérez les erreurs : Implémentez des mécanismes de gestion d'erreurs robustes dans vos actions, surtout pour les appels asynchrones.

Conclusion

La gestion de l'état est un pilier fondamental dans la construction d'applications Vue.js réactives et maintenables. Que vous optiez pour le modèle éprouvé de Vuex ou la simplicité moderne de Pinia, l'objectif est le même : fournir une "source de vérité unique" prévisible et facile à déboguer pour l'état de votre application.

  • Vuex a été le standard pendant des années, avec ses mutations synchrones et ses actions asynchrones, offrant une grande rigueur.
  • Pinia est la nouvelle norme recommandée pour Vue 3, simplifiant l'API, améliorant le support TypeScript et offrant une expérience de développement plus fluide.

En maîtrisant ces concepts, vous serez en mesure de concevoir des applications Vue.js robustes, scalables et faciles à maintenir, peu importe leur complexité. Pratiquez ces concepts et n'hésitez pas à expérimenter avec les deux bibliothèques pour bien comprendre leurs forces respectives.