Maîtrisez Pinia, la solution officielle de state management Vue 3 : stores, actions async, composition, persistance et tests avec Vitest.
Pourquoi Pinia remplace Vuex ?
Vuex 4 était la solution officielle de state management pour Vue 3, mais elle traînait un héritage lourd de Vue 2 : mutations obligatoires, boilerplate excessif, support TypeScript laborieux. Pinia a été créée par Eduardo San Martin Morote (membre de la core team Vue) comme réponse directe à ces problèmes. Depuis 2022, Pinia est la bibliothèque de state management officiellement recommandée par l'équipe Vue.
Vuex 4 vs Pinia : la différence concrète
// ❌ Vuex 4 — beaucoup de boilerplate, mutations séparées des actions
const store = createStore({
state: () => ({ count: 0, user: null }),
// Les mutations sont OBLIGATOIRES pour modifier le state
mutations: {
INCREMENT(state) { state.count++; },
SET_USER(state, user) { state.user = user; },
},
// Les actions appellent les mutations (indirection forcée)
actions: {
async fetchUser({ commit }) {
const user = await api.getUser();
commit('SET_USER', user); // Obligé de passer par commit
},
},
getters: {
doubleCount: state => state.count * 2,
},
});
// Utilisation : this.$store.commit('INCREMENT') ou useStore()
// ✅ Pinia — simple, intuitif, TypeScript natif
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, user: null }),
// Getters = computed()
getters: {
doubleCount: (state) => state.count * 2,
},
// Actions peuvent modifier le state DIRECTEMENT — pas de mutations !
actions: {
increment() {
this.count++; // Mutation directe ✅
},
async fetchUser() {
this.user = await api.getUser(); // Pas de commit() ✅
},
},
});
Avantages clés de Pinia
| Critère | Vuex 4 | Pinia |
|---|---|---|
| Mutations | Obligatoires | Supprimées — actions directes |
| TypeScript | Partiel, verbeux | Natif, inférence complète |
| DevTools | Intégré | Intégré (time-travel, hot reload) |
| Stores multiples | Modules complexes | Stores indépendants simples |
| Composition API | Limité | Natif (Setup Stores) |
| Bundle size | ~10kb | ~1.5kb (6x plus léger) |
| SSR | Complexe | Natif avec Nuxt 3 |
Installation et configuration
# Installation de Pinia
npm install pinia
// main.ts — Enregistrement de Pinia dans l'application Vue
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
const pinia = createPinia(); // Crée l'instance Pinia
app.use(pinia); // Enregistre Pinia comme plugin Vue
app.mount('#app');
Structure recommandée des stores
// Organisation recommandée du projet
src/
├── stores/
│ ├── auth.ts // Store d'authentification
│ ├── cart.ts // Store panier e-commerce
│ ├── ui.ts // Store UI (sidebar, modales, thème)
│ └── articles.ts // Store données métier
├── components/
└── composables/
use et le suffixe Store : useAuthStore,
useCartStore, useUiStore. Cette convention est
cohérente avec les composables Vue.
Créer son premier store
Un store Pinia se compose de trois éléments : le state (données), les getters (valeurs dérivées) et les actions (mutations et logique métier).
// stores/counter.ts — Store minimal bien structuré
import { defineStore } from 'pinia';
// defineStore(id, config) — l'id est unique dans l'application
export const useCounterStore = defineStore('counter', {
// state : fonction qui retourne l'état initial (comme data() dans Options API)
state: () => ({
count: 0,
history: [] as number[], // TypeScript : typer les tableaux vides
label: 'Mon compteur',
}),
// getters : valeurs calculées depuis le state (équivalent computed())
getters: {
// Le state courant est passé en argument — TypeScript l'infère
doubleCount: (state) => state.count * 2,
// Getter qui retourne une fonction (pour les getters paramétrés)
isAbove: (state) => (threshold: number) => state.count > threshold,
// Getter qui utilise un autre getter (via this)
summary(): string {
// "this" donne accès à l'ensemble du store
return `${this.label} : ${this.count} (x2 = ${this.doubleCount})`;
},
},
// actions : méthodes qui modifient le state ou déclenchent des effets
actions: {
// Mutation directe du state — pas de commit() !
increment() {
this.count++;
this.history.push(this.count); // Accès au state via this
},
decrement() {
this.count = Math.max(0, this.count - 1);
},
// Action avec paramètre
incrementBy(amount: number) {
this.count += amount;
},
reset() {
// $reset() réinitialise le state aux valeurs initiales
this.$reset();
},
},
});
<!-- Utilisation dans un composant Vue -->
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
const counterStore = useCounterStore();
// ✅ storeToRefs() : déstructure le state/getters en refs réactives
// (comme toRefs() pour reactive, mais pour les stores Pinia)
const { count, doubleCount, summary } = storeToRefs(counterStore);
// Les actions se déstructurent directement (pas besoin de storeToRefs)
const { increment, decrement, incrementBy, reset } = counterStore;
</script>
<template>
<div>
<p>{{ summary }}</p>
<p>Double : {{ doubleCount }}</p>
<button @click="decrement" :disabled="count === 0">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
<button @click="incrementBy(10)">+10</button>
<button @click="reset">Reset</button>
</div>
</template>
const { count } = useCounterStore() sans storeToRefs,
count sera une valeur primitive non réactive. Le template ne se
mettra jamais à jour. Utilisez toujours storeToRefs() pour le
state et les getters.
Style Option Store vs Setup Store
Pinia propose deux syntaxes pour définir un store. Le Option Store (vu précédemment) est similaire à l'Options API Vue. Le Setup Store utilise la Composition API directement — plus flexible et entièrement typé.
Setup Store : style Composition API
// stores/auth.ts — Setup Store (style Composition API)
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
// Setup Store : la fonction setup() est comme <script setup> d'un composant
export const useAuthStore = defineStore('auth', () => {
// --- STATE (équivalent à state: () => ({}) ) ---
const user = ref<User | null>(null);
const token = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// --- GETTERS (équivalent aux getters) ---
const isAuthenticated = computed(() => !!user.value && !!token.value);
const userDisplayName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : 'Invité'
);
const isAdmin = computed(() => user.value?.role === 'admin');
// --- ACTIONS (équivalent aux actions) ---
async function login(email: string, password: string) {
loading.value = true;
error.value = null;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Identifiants incorrects');
const data = await res.json();
user.value = data.user;
token.value = data.token;
// Persist dans localStorage
localStorage.setItem('auth_token', data.token);
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
user.value = null;
token.value = null;
localStorage.removeItem('auth_token');
}
async function fetchCurrentUser() {
const savedToken = localStorage.getItem('auth_token');
if (!savedToken) return;
token.value = savedToken;
try {
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (res.ok) user.value = await res.json();
} catch {
logout(); // Token invalide — déconnexion
}
}
// Tout ce qui est retourné est accessible depuis les composants
return {
user, token, loading, error, // State (refs)
isAuthenticated, userDisplayName, isAdmin, // Getters (computed)
login, logout, fetchCurrentUser, // Actions (fonctions)
};
});
<!-- Utilisation du Setup Store dans un composant -->
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const { user, isAuthenticated, userDisplayName, loading, error } = storeToRefs(authStore);
const { login, logout, fetchCurrentUser } = authStore;
// Vérifie si l'utilisateur est connecté au chargement de la page
onMounted(fetchCurrentUser);
const handleLogin = async () => {
await login('alice@example.com', 'password123');
};
</script>
<template>
<div>
<div v-if="isAuthenticated">
<p>Bonjour, {{ userDisplayName }}</p>
<button @click="logout">Se déconnecter</button>
</div>
<div v-else>
<button @click="handleLogin" :disabled="loading">
{{ loading ? 'Connexion...' : 'Se connecter' }}
</button>
<p v-if="error" role="alert">{{ error }}</p>
</div>
</div>
</template>
Comparaison des deux styles
| Critère | Option Store | Setup Store |
|---|---|---|
| Syntaxe | Options API (state/getters/actions) | Composition API (ref/computed/fn) |
| $reset() | ✅ Automatique | ❌ À implémenter manuellement |
| TypeScript | Bon | Excellent (inférence totale) |
| Flexibilité | Limitée | Totale (watchers, lifecycle…) |
| Lisibilité | Structure claire | Plus libre (organisation libre) |
Actions asynchrones et appels API
Les actions Pinia peuvent être async nativement. Elles gèrent les appels API, les erreurs et les états de chargement sans aucune configuration supplémentaire.
// stores/articles.ts — Store avec CRUD complet
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
interface Article {
id: number;
title: string;
content: string;
published: boolean;
createdAt: string;
}
export const useArticlesStore = defineStore('articles', () => {
const articles = ref<Article[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const selected = ref<Article | null>(null);
// Getters
const publishedArticles = computed(() =>
articles.value.filter(a => a.published)
);
const draftArticles = computed(() =>
articles.value.filter(a => !a.published)
);
const totalCount = computed(() => articles.value.length);
// Helper interne pour les appels API
const apiCall = async <T>(fn: () => Promise<T>): Promise<T | null> => {
loading.value = true;
error.value = null;
try {
return await fn();
} catch (err) {
error.value = (err as Error).message;
return null;
} finally {
loading.value = false;
}
};
// Actions CRUD
async function fetchAll() {
const data = await apiCall(() =>
fetch('/api/articles').then(r => r.json())
);
if (data) articles.value = data;
}
async function fetchById(id: number) {
const data = await apiCall(() =>
fetch(`/api/articles/${id}`).then(r => r.json())
);
if (data) selected.value = data;
}
async function create(payload: Omit<Article, 'id' | 'createdAt'>) {
const newArticle = await apiCall(() =>
fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json())
);
if (newArticle) articles.value.push(newArticle); // Mise à jour optimiste locale
return newArticle;
}
async function update(id: number, payload: Partial<Article>) {
const updated = await apiCall(() =>
fetch(`/api/articles/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json())
);
if (updated) {
// Remplace l'article dans le tableau local
const index = articles.value.findIndex(a => a.id === id);
if (index !== -1) articles.value[index] = updated;
}
return updated;
}
async function remove(id: number) {
const success = await apiCall(() =>
fetch(`/api/articles/${id}`, { method: 'DELETE' }).then(r => r.ok)
);
if (success) {
articles.value = articles.value.filter(a => a.id !== id);
}
}
return {
articles, loading, error, selected,
publishedArticles, draftArticles, totalCount,
fetchAll, fetchById, create, update, remove,
};
});
Composer les stores entre eux
Contrairement aux modules Vuex, les stores Pinia sont indépendants et peuvent s'importer mutuellement. Un store peut utiliser un autre store directement dans ses actions.
// stores/cart.ts — Store panier qui utilise authStore
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useAuthStore } from './auth'; // Import direct du store auth
interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.productId === product.productId);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...product, quantity: 1 });
}
}
function removeItem(productId: number) {
items.value = items.value.filter(i => i.productId !== productId);
}
function clear() {
items.value = [];
}
async function checkout() {
// Utilise authStore directement depuis l'action
const authStore = useAuthStore(); // ✅ Appel dans l'action, pas au niveau module
if (!authStore.isAuthenticated) {
throw new Error('Vous devez être connecté pour commander');
}
const order = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.token}`,
},
body: JSON.stringify({
userId: authStore.user?.id,
items: items.value,
total: total.value,
}),
}).then(r => r.json());
clear(); // Vide le panier après commande réussie
return order;
}
return { items, total, itemCount, addItem, removeItem, clear, checkout };
});
Persistance avec pinia-plugin-persistedstate
Par défaut, les stores Pinia perdent leur état au rechargement de la page. Le plugin pinia-plugin-persistedstate synchronise automatiquement le state avec localStorage ou sessionStorage.
# Installation du plugin de persistance
npm install pinia-plugin-persistedstate
// main.ts — Enregistrement du plugin
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); // ✅ Active la persistance globalement
createApp(App).use(pinia).mount('#app');
// stores/preferences.ts — Store persisté automatiquement
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const usePreferencesStore = defineStore('preferences', () => {
const theme = ref<'light' | 'dark'>('light');
const language = ref('fr');
const fontSize = ref(16);
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light';
}
return { theme, language, fontSize, toggleTheme };
}, {
// Option persist : active la persistance pour ce store
persist: true, // Persiste tout le state dans localStorage (clé = store id)
});
// stores/auth.ts — Persistance sélective (token seulement)
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(null);
// ...autres state et actions
}, {
persist: {
// Persiste SEULEMENT le token (pas le user — refetch à chaque session)
pick: ['token'],
// Options avancées
storage: sessionStorage, // sessionStorage au lieu de localStorage
key: 'my-app-auth', // Clé personnalisée dans le storage
serializer: { // Sérialisation personnalisée
serialize: JSON.stringify,
deserialize: JSON.parse,
},
},
});
DevTools et tests des stores
Vue DevTools avec Pinia
Pinia s'intègre automatiquement avec les Vue DevTools. Vous pouvez inspecter l'état de chaque store, modifier les valeurs en direct, et rejouer les actions (time-travel debugging).
// $patch() : modifier plusieurs propriétés atomiquement
// Utile pour les mises à jour groupées (une seule entrée dans DevTools)
const store = useCounterStore();
// Avec objet — propriétés fusionnées
store.$patch({ count: 10, label: 'Nouveau label' });
// Avec fonction — pour les mutations complexes (tableaux, logique)
store.$patch((state) => {
state.count = 10;
state.history.push(10); // Les méthodes tableau fonctionnent
state.label = 'Nouveau label';
});
// $subscribe() : observer les changements du state
const unsubscribe = store.$subscribe((mutation, state) => {
// mutation.type : 'direct' | 'patch object' | 'patch function'
console.log('State modifié :', mutation.type, state.count);
});
// Appeler unsubscribe() pour arrêter l'observation
// $onAction() : observer les appels d'actions
store.$onAction(({ name, args, after, onError }) => {
console.log(`Action "${name}" appelée avec`, args);
after((result) => console.log('Résultat :', result)); // Après succès
onError((error) => console.error('Erreur :', error)); // En cas d'erreur
});
Tester les stores avec Vitest
// stores/counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from './counter';
describe('useCounterStore', () => {
// Crée une instance Pinia fraîche avant chaque test
beforeEach(() => {
setActivePinia(createPinia());
});
it('initialise le state par défaut', () => {
const store = useCounterStore();
expect(store.count).toBe(0);
expect(store.doubleCount).toBe(0);
});
it('incrémente le compteur', () => {
const store = useCounterStore();
store.increment();
store.increment();
expect(store.count).toBe(2);
expect(store.doubleCount).toBe(4);
});
it('respecte la limite minimale au decrement', () => {
const store = useCounterStore();
store.decrement(); // Essaie de passer en dessous de 0
expect(store.count).toBe(0); // Doit rester à 0
});
it('reset le state', () => {
const store = useCounterStore();
store.incrementBy(50);
store.reset();
expect(store.count).toBe(0);
});
});
// Test d'un store avec mock des appels API
import { vi } from 'vitest';
describe('useAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
// Réinitialise les mocks entre chaque test
vi.restoreAllMocks();
});
it('connecte un utilisateur avec succès', async () => {
const store = useAuthStore();
// Mock de fetch pour simuler une réponse API
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { id: 1, firstName: 'Alice', role: 'user' },
token: 'fake-jwt-token',
}),
} as Response);
await store.login('alice@example.com', 'password');
expect(store.isAuthenticated).toBe(true);
expect(store.user?.firstName).toBe('Alice');
expect(store.token).toBe('fake-jwt-token');
});
it("gère les erreurs de connexion", async () => {
const store = useAuthStore();
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: false,
} as Response);
await store.login('mauvais@email.com', 'mauvais');
expect(store.isAuthenticated).toBe(false);
expect(store.error).toBeTruthy();
});
});
Checklist Pinia
Configuration et structure
- Pinia installé et enregistré dans
main.tsavecapp.use(pinia) - Stores dans
src/stores/, nommésuseXxxStore - Setup Store préféré pour les nouveaux stores (meilleur TypeScript)
- Un store = une responsabilité (auth, cart, ui…)
Utilisation dans les composants
- Toujours
storeToRefs()pour déstructurer state et getters - Actions déstructurées directement sans storeToRefs
- Importer les stores dépendants à l'intérieur des actions (pas au niveau module)
- Utiliser
$patch()pour les mises à jour groupées
Tests et qualité
setActivePinia(createPinia())dansbeforeEach- Mocker fetch ou les services dans les tests de stores async
- Tester le state initial, les actions et les getters séparément
- Ne persister que les données nécessaires (éviter les données sensibles)
Pinia représente l'état de l'art du state management dans l'écosystème Vue 3. Sa légèreté, son API intuitive et son support TypeScript natif en font un outil que l'on apprend en quelques heures mais qu'on utilise quotidiennement dans tous les projets Vue sérieux. La suppression des mutations et la possibilité de muter directement le state dans les actions réduit considérablement le boilerplate et rend le code plus lisible et maintenable.