Apprendre Vue 3 : Data Store avec Pinia

Pinia est la nouvelle bibliothèque de gestion d'état centralisée qui prend le pas sur son homologue VueX.

Icône de calendrier
Intermédiaire
9 chapitres

Qu’est-ce que Pinia ?

Data-store centralisé

Pinia est une bibliothèque de stockage ou gestionnaire d’étatstate 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 :

copié !
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() :

main.js
copié !
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 :

stores/counter.js
copié !
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és state, getters et actions.

State

L’état global de l’application est défini au sein d’une propriété state.

stores/counter.js
copié !
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.

stores/counter.js
copié !
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.

stores/counter.js
copié !
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.

MyComponent.vue
copié !
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 :

MyComponent.vue
copié !
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 :

main.js
copié !
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 :

App.vue
copié !
<template>
	<ComponentA/>
	<ComponentB/>
</template>

On déclare un store counter.js :

stores/counter.js
copié !
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 :

ComponentA.vue
copié !
<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 :

ComponentB.vue
copié !
<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.

stores/marketStore.js
copié !
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)
		}
	}
})
App.vue
copié !
<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>
NavBar.vue
copié !
<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>
ProductCard.vue
copié !
<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>