Tutoriel Pinia pour débutants

Dans Vue3, la gestion d'état est un sujet incontournable.

Pinia est une bibliothèque légère de gestion d'état pour Vue.js qui vous permet de partager et de gérer l'état entre les composants. On peut considérer Pinia comme un entrepôt de données global dans lequel tous les composants peuvent récupérer ou mettre à jour des données.

Dans ce chapitre, nous présentons Pinia, la bibliothèque de gestion d'état officiellement recommandée pour Vue3. Comparé à Vuex, Pinia offre une solution de gestion d'état plus simple qui correspond mieux à l'esprit de la Composition API de Vue3.

Le diagramme suivant compare les différences structurelles entre les deux :

  • Vuex utilise un seul store avec des modules à plusieurs niveaux dans une structure arborescente, avec une hiérarchie fixe, s'appuie sur des mutations et est globalement plus lourd.
  • Pinia, en revanche, se compose de plusieurs stores indépendants ; il est plat, léger, sans séparation en modules et sans surcoût de namespace.

Fonctionnalités principales de Pinia :

  • Prend en charge Vue2 et Vue3
  • Conception d'API minimaliste
  • Prise en charge complète de TypeScript
  • Prend en charge la Composition API
  • Conception modulaire sans modules imbriqués

La structure plate de Pinia permet aux composants de se connecter directement à n'importe quel store et de prendre ce dont ils ont besoin, sans passer par des modules arborescents ni des chemins de namespace. Le flux d'état est simple et direct, avec un faible couplage.


Installation et configuration

Créer un projet avec Vite :

npm create vite@latest vue-pinia-demo --template vue
cd vue-pinia-demo
npm install

Installer Pinia

Tout d'abord, installez Pinia dans votre projet Vue3 :

npm install pinia
# 或者
yarn add pinia

Pinia comporte trois parties :

  • state : stocke les données
  • getters : calcule les données dérivées
  • actions : exécute la logique, modifie le state

Configurer Pinia

Configurer Pinia dans src/main.js ou src/main.ts :

Exemple

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// Créer l'instance Pinia
const pinia = createPinia()
// Créer l'application Vue
const app = createApp(App)

// Utiliser Pinia
app.use(pinia)
app.mount('#app')

Créer votre premier store

Un store est l'entrepôt de données dans Pinia. Créons un store de compteur simple.

Définir un store

Créer un nouveau fichier src/stores/useCounter.js :

Exemple

// stores/useCounter.js
import { defineStore } from 'pinia'

// Définir un store avec defineStore
// Le premier paramètre est l'ID unique du store
// Le second paramètre correspond aux options de configuration du store
export const useCounterStore = defineStore('counter', {
  // state : définit les données d'état du store
  state: () => ({
    count: 0,
    name: 'Mon compteur'
  }),

  // getters : définit des propriétés calculées à partir de state
  getters: {
    doubleCount: (state) => state.count * 2,
    // Utilisez ceci pour accéder aux autres getters
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // actions : définit des méthodes pour modifier l'état
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    // Peut accepter des paramètres
    incrementBy(amount) {
      this.count += amount
    },
    // Action asynchrone
    async incrementAsync() {
      // Simuler une opération asynchrone
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})

Explication de la structure du store

Comprenons les différentes parties d'un store à travers un tableau :

Partie Objectif Exemple
state Définit les données stockées count: 0
getters Propriétés calculées en fonction de l'état doubleCount: state => state.count * 2
actions Méthodes pour modifier l'état increment() { this.count++ }

Utilisation du Store dans les composants

Utilisation de base

Le code du composant pour src/components/CounterComponent.vue est le suivant :

Exemple

<!-- CounterComponent.vue -->
<template>
  <div class="counter">
    <h3>{{ store.name }}</h3>
    <p>Nombre actuel : {{ store.count }}</p>
    <p>Nombre doublé : {{ store.doubleCount }}</p>
    <p>Nombre doublé plus un : {{ store.doubleCountPlusOne }}</p>
   
    <button @click="store.increment()">+1</button>
    <button @click="store.decrement()">-1</button>
    <button @click="store.incrementBy(5)">+5</button>
    <button @click="store.incrementAsync()">Asynchrone +1</button>
   
    <button @click="reset">Réinitialiser</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '../stores/useCounter'

// Utiliser le store dans setup
const store = useCounterStore()

// Méthode pour réinitialiser l'état
function reset() {
store.$reset() // La méthode $reset peut réinitialiser l'état à sa valeur initiale
}
</script>

Modifier src/App.vue comme suit :

Exemple

<script setup>
import CounterComponent from './components/CounterComponent.vue'
</script>

<template>
  <CounterComponent />
</template>

La structure complète du projet :

Exécuteznpm run devcommande, et visitez dans le navigateurhttp://localhost:5173/, et voyez l'effet :

Déstructuration réactive

Si vous voulez utiliser directement les propriétés d'état dans le template, vous pouvez utiliserstoreToRefs:

Exemple

<template>
  <div>
    <p>Nombre : {{ count }}</p>
    <p>Nom : {{ name }}</p>
  </div>
</template>

<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()

// Utilisez storeToRefs pour conserver la réactivité
const { count, name } = storeToRefs(store)

// Remarque : la déstructuration directe perdra la réactivité !
// Mauvaise façon : const { count, name } = store
</script>

Store de style Composition API

Pinia prend également en charge la définition de stores en utilisant le style Composition API :

Exemple

// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // état
  const user = ref(null)
  const isLoggedIn = ref(false)

  // accesseurs
  const userName = computed(() => user.value?.name || 'Invité')
  const userAge = computed(() => user.value?.age || 0)

  // actions
  function login(userData) {
    user.value = userData
    isLoggedIn.value = true
  }

  function logout() {
    user.value = null
    isLoggedIn.value = false
  }

  return {
    user,
    isLoggedIn,
    userName,
    userAge,
    login,
    logout
  }
})

Interaction entre les stores

Plusieurs stores peuvent s'appeler entre eux:

Exemple

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),

  actions: {
    addItem(product) {
      this.items.push(product)
     
      // Appeler une action depuis un autre store
      const userStore = useUserStore()
      if (userStore.isLoggedIn) {
        // Synchroniser avec le panier d'achat de l'utilisateur
        this.syncToUserCart()
      }
    }
  }
})

Exemple pratique : Panier d'achat

Créons un exemple complet de panier d'achat :

Exemple

// stores/products.js
export const useProductStore = defineStore('products', {
  state: () => ({
    products: [
      { id: 1, name: 'Laptop', price: 5999, stock: 10 },
      { id: 2, name: 'Smartphone', price: 3999, stock: 20 },
      { id: 3, name: 'Écouteurs sans fil', price: 299, stock: 50 }
    ]
  }),

  getters: {
    // Obtenir le produit par ID
    getProductById: (state) => (id) => {
      return state.products.find(product => product.id === id)
    },
    // Obtenir les produits en stock
    availableProducts: (state) => {
      return state.products.filter(product => product.stock > 0)
    }
  }
})

Exemple

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [], // { productId, quantity }
    discount: 0
  }),

  getters: {
    // Nombre total d'articles dans le panier
    totalItems: (state) => {
      return state.items.reduce((total, item) => total + item.quantity, 0)
    },
    // Montant total du panier
    totalPrice: (state) => {
      const productStore = useProductStore()
      return state.items.reduce((total, item) => {
        const product = productStore.getProductById(item.productId)
        return total + (product?.price || 0) * item.quantity
      }, 0)
    },
    // Prix total après réduction
    finalPrice: (state) => {
      return state.totalPrice * (1 - state.discount / 100)
    }
  },

  actions: {
    // Ajouter un produit au panier
    addToCart(productId, quantity = 1) {
      const existingItem = this.items.find(item => item.productId === productId)
     
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({ productId, quantity })
      }
    },

    // Supprimer le produit du panier
    removeFromCart(productId) {
      this.items = this.items.filter(item => item.productId !== productId)
    },

    // Vider le panier
    clearCart() {
      this.items = []
      this.discount = 0
    },

    // Définir la remise
    setDiscount(percent) {
      this.discount = Math.max(0, Math.min(100, percent))
    }
  }
})

Utilisation du panier d'achat dans un composant

Exemple

<!-- ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <h3>Panier d'achat ({{ cart.totalItems }} articles)</h3>
   
    <div v-if="cart.items.length === 0">
      <p>Le panier est vide</p>
    </div>
   
    <div v-else>
      <div v-for="item in cartItems" :key="item.product.id" class="cart-item">
        <span>{{ item.product.name }}</span>
        <span>¥{{ item.product.price }}</span>
        <span>Quantité : {{ item.quantity }}</span>
        <span>Sous-total : ¥{{ item.product.price * item.quantity }}</span>
        <button @click="cart.removeFromCart(item.product.id)">Supprimer</button>
      </div>
     
      <div class="cart-summary">
        <p>Total : ¥{{ cart.totalPrice }}</p>
        <p v-if="cart.discount >0">Remise : {{ cart.discount }}%</p>
        <p>Montant payé : ¥{{ cart.finalPrice }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useProductStore } from '@/stores/products'

const cart = useCartStore()
const products = useProductStore()

// Calculer les détails des articles du panier
const cartItems = computed(() => {
  return cart.items.map(item => ({
    product: products.getProductById(item.productId),
    quantity: item.quantity
})).filter(item => item.product) // Filtrer les produits qui n'existent pas
})
</script>

Bonnes pratiques et considérations

1. Conventions de nommage des stores

Exemple

// Bon nommage
export const useUserStore = defineStore('user', { /* ... */ })
export const useProductStore = defineStore('products', { /* ... */ })

// Nommage à éviter
export const userStore = defineStore('user', { /* ... */ }) // Préfixe use manquant

2. Initialisation de l'état

Exemple

// Recommandé : utiliser une fonction pour renvoyer l'état initial
state: () => ({
  items: [],
  loading: false,
  error: null
})

// Non recommandé : utiliser directement un objet
state: {
  items: []  // Cela fera que toutes les instances partageront le même tableau !
}

3. Gestion des opérations asynchrones

Exemple

actions: {
  async fetchUserData(userId) {
    this.loading = true
    this.error = null
   
    try {
      const response = await api.getUser(userId)
      this.user = response.data
    } catch (error) {
      this.error = error.message
    } finally {
      this.loading = false
    }
  }
}

4. Persistance des données

Pour les données qui doivent être persistées, vous pouvez utiliser un plugin :

npm install pinia-plugin-persistedstate

Exemple

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {}
  }),
 
  // Activer la persistance
  persist: true
})

API Pinia

Catégorie API Description
Créer defineStore Définir un store indépendant, contenant state / getters / actions
Instanciation createPinia Créer une instance racine Pinia pour app.use()
Utilisation des composants useStore()(Défini par l'utilisateur, par ex.useUserStore) Appeler un store, renvoie l'état réactif, les getters et les actions
State state Renvoie un objet fonction, utilisé pour définir un état global mutable
Opérations sur l'état store.$state Lire/écrire directement tout l'état (au niveau de l'objet)
Opérations sur l'état store.$patch() Modifier l'état par lots, prend en charge les modes objet et fonction
Remplacement de l'état store.$reset() Réinitialiser à l'état initial, disponible uniquement lorsqu'il est défini via la méthode setup
Abonnement à l'état store.$subscribe() Écouter les changements d'état, adapté à la persistance locale
Invocation d'action store.$onAction() Écouter avant et après les appels d'action, peut être utilisé pour la journalisation et le suivi
Getter getters Données dérivées, automatiquement mises en cache en fonction de l'état
Plugins pinia.use() Enregistrer des plugins pour étendre les capacités du store
Méthode de définition defineStore(id, options) Syntaxe de l'API Options
Méthode de définition defineStore(id, () => {...}) Syntaxe setup, renvoie state / getter / action
Propriétés du store store.$id Identifiant unique du store actuel
Propriétés du store store.$ready(Certaines versions) Indicateur d'état une fois l'initialisation du store terminée
Persistance (plugin) persist Activer les plugins de stockage (tels que localStorage)
Fonctions utilitaires storeToRefs() Convertir state / getters en refs, en préservant la réactivité

Pour plus d'informations, consultez le site officiel de Pinia :https://pinia.vuejs.org/

Autres extensions