Formation Vue 3 | State Centralisé avec Pinia (Store)
Pinia est la bibliothèque de gestion d'état centralisée qui succède son n ancien homologue VueX.
Qu’est-ce que Pinia ?
Data-store centralisé
Pinia est une bibliothèque de stockage ou gestionnaire d’état (« state management pattern ») pour Vue.js. Il permet de partager un état entre les composants/pages de l’application par l’intermédiaire d’une zone de stockage partagée appelée store.
On parle de « Data-store centralisé ».
Il définit également des règles afin de s’assurer que l’état ne subisse de mutations que d’une manière prévisible (de la même manière que les setters d’un objet en POO).
Pourquoi utiliser Pinia ?
Le partage de données entre un composant parent et enfant peut être réalisé classiquement via des props
et évènements Vue.js.
Néanmoins, si nous souhaitons partager un état entre de nombreuses pages/composants, cela risquera bien souvent de nous conduire à des magouilles de code entraînant indéniablement fragilité et difficulté de maintenance.
La solution consiste donc à extraire l’état global partagé des composants dans un singleton global (classe dont il n’existe qu’une seule instance) appelé “data-store”. Et ce data-store, c’est Pinia qui nous en propose une implémentation. 🎉
Installation
Pinia est téléchargeable via votre gestionnaire de paquet favori tel que npm
:
npm install pinia
Néanmoins, je vous recommande de l’installer dès l’initialisation de votre projet avec Vite en sélectionnant Yes
, lors de la phase de configuration.
Vue.js - The Progressive JavaScript Framework
...
√ Add Pinia for state management? ... No / Yes
...
Procéder comme cela vous permettra de pré-configurer automatiquement votre application Vue.js pour travailler avec Pinia.
Initialisation
Pour initialiser Pinia au sein de votre Application Vue.js, vous devez importer la méthode createApp()
au sein du fichier 📄 main.js
puis l’enregistrer avec app.use()
:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// ...
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
Définir un store
Tout comme Vue.js, la bibliothèque Pinia propose 2 approches syntaxiques : Options Stores
et Setup Stores
, respectivement basées sur Options API
et Composition API
.
Tout comme pour Vue.js, il est recommandé de s’initier à la bibliothèque avec Options Stores
.
Vous pouvez définir autant de stores que vous le souhaitez, au sein du dossier 📂 src/stores
.
Voici un exemple de store nommé 📄 counter.js
, qui servira à partager un compteur entre tous ses composants :
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {})
Un store est défini au sein d’un constante nommée selon la convention : use{nomDuStore}Store
. Cette constante est exportée avec le mot-clé export
afin de pouvoir être importée par les composants de l’application.
La fonction defineStore()
contient 2 paramètres :
- L’identifiant/le nom du store (ici
counter
) - Les options du store (au sein d’un objet, selon l’approche
Options API
). On y définira les propriétésstate
,getters
etactions
.
State
L’état global de l’application est défini au sein d’une propriété state
.
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
counter: 0
})
})
Le state
est une fonction fléchée qui retourne un objet JS définissant les propriétés de l’état global.
Ces propriétés seront réactives, de la même manière que l’état d’un composant Vue.js (data
).
Actions
La logique métier à appliquer sur les propriétés du state est définie au sein d’une propriété actions
.
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
// ...
}),
actions: {
increment() {
this.counter++
},
}
})
Getters
Des valeurs de retours calculées à partir de l’état sont définies au sein d’une propriété getters
. Ils reçoivent l’état en tant que premier paramètre.
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
// ...
}),
actions: {
// ...
},
getters: {
isEven: (state) => {
return state.counter % 2 == 0
}
}
})
Accéder au store
Pour accéder aux éléments d’un store, il faut d’abord importer ce dernier depuis un composant :
Ensuite, avec Options State
, on fait appel à la fonction helper mapStores()
afin de mapper l’ensemble des éléments du store au sein de propriétés calculées.
import { mapStores } from 'pinia'
import { useCounterStore } from '../stores/counter'
export default {
computed: {
// Store accessible via l'objet this.counterStore
...mapStores(useCounterStore),
}
}
Ces propriétés calculées sont accessibles via un objet nommé selon l’identifiant du store + Store
.
Par exemple :
counterStore.counter // Pour le state
counterStore.increment() // Pour une action
counterStore.isEven // Pour un getter
Le store étant désormais accessible on peut y accéder depuis les templates avec la syntaxe {{ ... }}
ou les scripts, en préfixant l’objet par this
.
Cas pratique
Compteur
On enregistre globalement les composants ComponentA.vue
et ComponentB.vue
:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import ComponentA from './components/ComponentA.vue'
import ComponentB from './components/ComponentB.vue'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.component('ComponentA', ComponentA)
app.component('ComponentB', ComponentB)
app.mount('#app')
On utilise ces composants dans App.vue
:
<template>
<ComponentA/>
<ComponentB/>
</template>
On déclare un store counter.js
:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
counter: 0
}),
actions: {
increment() {
this.counter++
}
},
getters: {
isEven: (state) => {
return state.counter % 2 == 0
}
}
})
On peut désormais utiliser ce store partagé depuis plusieurs composants.
J’incrémente le compteur du store depuis ComponentA
:
<template>
<h1>Component A</h1>
<button @click="counterStore.increment()">Ajouter</button>
</template>
<script>
import { useCounterStore } from '../stores/counter'
import { mapStores } from 'pinia'
export default {
computed: {
...mapStores(useCounterStore),
}
}
</script>
Je récupère sa valeur depuis ComponentB
:
<template>
<h1>Component B</h1>
<p>{{ counterStore.counter }}</p>
</template>
<script>
import { useCounterStore } from '../stores/counter'
import { mapStores } from 'pinia'
export default {
computed: {
...mapStores(useCounterStore),
}
}
</script>
Gestion panier e-commerce
Voici un cas pratique illustrant l’intérêt d’utiliser un data-store afin de centraliser les produits et le panier disponible sur un site e-commerce.
import { defineStore } from 'pinia'
export const useMarketStore = defineStore('market', {
state: () => ({
products: [
{
name: 'ananas',
emoji: '🍍',
price: 2.2,
discount: true
},
{
name: 'pomme',
emoji: '🍎',
price: 0.6,
discount: false,
},
{
name: 'banane',
emoji: '🍌',
price: 0.35,
discount: true
}
],
cart: []
}),
actions: {
addToCart(item) {
this.cart.push(item)
},
},
getters: {
discounted: (state) => {
return state.products.filter(product => product.discount)
}
}
})
<template>
<NavBar></NavBar>
<ProductCard v-for="product in marketStore.products" :key="product.name" :product="product"></ProductCard>
</template>
<script>
import ProductCard from './components/ProductCard.vue'
import NavBar from './components/NavBar.vue'
import { mapStores } from 'pinia'
import { useMarketStore } from './stores/market'
export default {
components: {
ProductCard,
NavBar
},
computed: {
...mapStores(useMarketStore),
}
}
</script>
<template>
<header class="navbar">
Nombre de produits : {{ marketStore.cart }}
</header>
</template>
<script>
import { mapStores } from 'pinia'
import { useMarketStore } from '../stores/market'
export default {
name: 'NavBar',
computed: {
...mapStores(useMarketStore),
}
}
</script>
<template>
<article class="product">
<h2 class="product-name">{{ product.emoji }} {{ product.name }}</h2>
<span class="product-price">{{ product.price }}€</span>
<button @click="marketStore.addToCart(product)">Ajouter au panier</button>
</article >
</template>
<script>
import { mapStores } from 'pinia'
import { useMarketStore } from '../stores/market'
export default {
name: 'ProductCard',
props: {
product: Object
},
computed: {
...mapStores(useMarketStore),
}
}
</script>