Front-end

Pinia : state management Vue 3

Vue-3 Pinia State-Management Store Setup-Store Storetorefs Patch Pinia-Plugin-Persistedstate Nuxt-3 Ssr Broadcastchannel Vitest
Pinia : state management Vue 3

Pinia Vue 3 : Setup Store, storeToRefs, $patch, plugins, persistedstate, $onAction, SSR Nuxt et patterns de communication entre stores 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
Pinia est la bibliothèque officielle Vue 3. Si vous démarrez un nouveau projet Vue 3, utilisez Pinia. Si vous avez un projet Vuex, la migration est progressive : Pinia et Vuex peuvent coexister le temps de la transition.

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/
Convention : Les stores Pinia se nomment avec le préfixe 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>
storeToRefs() est essentiel : Si vous déstructurez directement 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 };
});
Important : Pour éviter les dépendances circulaires entre stores, importez toujours le store dépendant à l'intérieur de la fonction action (comme dans l'exemple ci-dessus), jamais au niveau du module. Pinia gère le singleton correctement dans ce cas.

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,
    },
  },
});
Sécurité : Ne persistez jamais de données sensibles en clair dans localStorage (mots de passe, numéros de carte). Pour les tokens JWT, une bonne pratique est de les stocker en mémoire (ref) et d'utiliser un cookie HttpOnly pour la persistance côté serveur.

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.ts avec app.use(pinia)
  • Stores dans src/stores/, nommés useXxxStore
  • 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()) dans beforeEach
  • 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)
Quand utiliser Pinia vs composable ? Utilisez Pinia pour l'état partagé globalement (auth, panier, thème, préférences). Utilisez les composables pour la logique locale à un composant ou un sous-arbre. Si plusieurs composants non liés ont besoin des mêmes données, c'est un store. Si c'est réutilisable mais local, c'est un composable.

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.

$patch — mutation atomique multi-propriétés

Modifier plusieurs propriétés du state séparément génère un événement DevTools par modification. $patch regroupe les changements en une seule transaction visible dans le timeline DevTools, et déclenche un seul cycle de réactivité au lieu de N.

// Inefficace — 3 updates réactifs, 3 events DevTools
store.firstName = 'Alice';
store.lastName = 'Doe';
store.email = 'alice@example.com';

// Optimisé — 1 update, 1 event
store.$patch({
    firstName: 'Alice',
    lastName: 'Doe',
    email: 'alice@example.com',
});

// Variante fonction pour mutations dérivées
store.$patch((state) => {
    state.items.push({ id: 1, name: 'Book' });
    state.total += 19.99;
});

$reset, $subscribe, $onAction

Trois APIs additionnelles utiles en production :

  • store.$reset() remet le state à sa valeur initiale (utile au logout, à la fermeture d'un dialogue, à la sortie d'un wizard).
  • store.$subscribe((mutation, state) => {...}) écoute toutes les mutations du store. Utile pour la persistance custom ou le debugging.
  • store.$onAction(({ name, args, after, onError }) => {...}) intercepte les appels d'actions. Idéal pour le logging analytics ou la mesure de performance.

Plugins Pinia — étendre les stores

Un plugin Pinia est une fonction qui reçoit chaque store à sa création et peut le modifier. C'est le mécanisme officiel pour ajouter des comportements transversaux (persistance, logger, time-travel) sans toucher au code des stores eux-mêmes.

// plugin-logger.ts
import type { PiniaPluginContext } from 'pinia';

export function loggerPlugin({ store }: PiniaPluginContext) {
    store.$subscribe((mutation, state) => {
        console.log(`[${store.$id}] ${mutation.type}`, mutation.payload, state);
    });
    store.$onAction(({ name, args, after, onError }) => {
        const start = Date.now();
        after((result) => {
            console.log(`[${store.$id}] action "${name}" (${Date.now() - start}ms)`);
        });
        onError((err) => console.error(`[${store.$id}] action "${name}" failed:`, err));
    });
}

// main.ts
const pinia = createPinia();
pinia.use(loggerPlugin);
app.use(pinia);

L'écosystème Pinia propose des plugins prêts à l'emploi : pinia-plugin-persistedstate (persistance localStorage/sessionStorage/cookies), pinia-shared-state (sync multi-onglets via BroadcastChannel), pinia-plugin-debounce (debounce automatique d'actions). La plupart tiennent en 30-100 lignes — vous pouvez aisément écrire les vôtres pour des besoins métier spécifiques.

Pinia avec Nuxt 3 — pinia-plugin-persistedstate + SSR

Sur Nuxt 3, Pinia s'installe via @pinia/nuxt qui gère automatiquement la SSR : l'état créé côté serveur est sérialisé dans le HTML, puis hydraté côté client sans rejouer les requêtes initiales. Aucune configuration supplémentaire n'est nécessaire pour les stores classiques.

// nuxt.config.ts
export default defineNuxtConfig({
    modules: ['@pinia/nuxt', '@pinia-plugin-persistedstate/nuxt'],
    pinia: { storesDirs: ['./stores/**'] },
});

// stores/auth.ts — auto-importé par Nuxt
export const useAuthStore = defineStore('auth', () => {
    const user = ref<User | null>(null);
    async function fetchUser() {
        const { data } = await useFetch<User>('/api/me');
        user.value = data.value;
    }
    return { user, fetchUser };
}, {
    persist: {
        storage: persistedState.cookiesWithOptions({ sameSite: 'strict' }),
    },
});

Le plugin persistedState.cookiesWithOptions persiste l'état dans des cookies — accessibles côté serveur Nuxt pour l'hydratation initiale. C'est le pattern à adopter pour l'authentification SSR : pas de flash de contenu non authentifié au chargement.

Mini-projet appliqué — store auth + cart e-commerce

Cas concret : 2 stores Pinia clés pour un e-commerce Vue 3useAuthStore (login/logout + persist cookies SSR-safe) et useCartStore (panier + calculs dérivés + sync localStorage). C'est le squelette qu'on retrouve dans 80 % des shop Vue 3 production.

1. Auth Store — Setup Store + persist

Pour le pattern complet auth tokens (cookies httpOnly + refresh), voir le mini-projet auth tokens 2026.

// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface User {
    id: string;
    email: string;
    fullName: string;
    role: 'customer' | 'admin';
}

export const useAuthStore = defineStore('auth', () => {
    const user = ref<User | null>(null);
    const status = ref<'idle' | 'loading' | 'authenticated' | 'error'>('idle');
    const error = ref<string | null>(null);

    const isAuthenticated = computed(() => user.value !== null);
    const isAdmin = computed(() => user.value?.role === 'admin');
    const displayName = computed(() => user.value?.fullName ?? 'Invité');

    async function login(email: string, password: string) {
        status.value = 'loading';
        error.value = null;
        try {
            const res = await fetch('/api/auth/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include', // cookie httpOnly refresh token
                body: JSON.stringify({ email, password }),
            });
            if (!res.ok) throw new Error('Login failed');
            const data = await res.json();
            user.value = data.user;
            status.value = 'authenticated';
        } catch (e) {
            error.value = e instanceof Error ? e.message : 'Erreur inconnue';
            status.value = 'error';
            throw e;
        }
    }

    async function logout() {
        await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
        user.value = null;
        status.value = 'idle';
    }

    return { user, status, error, isAuthenticated, isAdmin, displayName, login, logout };
}, {
    // Persistance partielle (le refresh token reste en cookie httpOnly côté serveur)
    persist: {
        paths: ['user'],
    },
});

2. Cart Store — actions + getters dérivés

// stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useAuthStore } from './auth';

interface CartItem {
    productId: string;
    name: string;
    price: number;
    quantity: number;
    imageUrl: string;
}

export const useCartStore = defineStore('cart', () => {
    const items = ref<CartItem[]>([]);
    const couponCode = ref<string | null>(null);
    const couponDiscount = ref(0);

    // Getters dérivés — auto-recalculés
    const itemCount = computed(() =>
        items.value.reduce((sum, i) => sum + i.quantity, 0)
    );
    const subtotal = computed(() =>
        items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
    );
    const shipping = computed(() => subtotal.value > 50 ? 0 : 5.90);
    const discount = computed(() => subtotal.value * couponDiscount.value);
    const total = computed(() => subtotal.value + shipping.value - discount.value);
    const isEmpty = computed(() => items.value.length === 0);

    // Actions
    function addItem(item: Omit<CartItem, 'quantity'>) {
        const existing = items.value.find(i => i.productId === item.productId);
        if (existing) {
            existing.quantity++;
        } else {
            items.value.push({ ...item, quantity: 1 });
        }
    }

    function removeItem(productId: string) {
        items.value = items.value.filter(i => i.productId !== productId);
    }

    function updateQuantity(productId: string, quantity: number) {
        const item = items.value.find(i => i.productId === productId);
        if (!item) return;
        if (quantity <= 0) removeItem(productId);
        else item.quantity = quantity;
    }

    async function applyCoupon(code: string) {
        const res = await fetch('/api/coupons/validate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ code }),
        });
        if (!res.ok) {
            couponCode.value = null;
            couponDiscount.value = 0;
            throw new Error('Code invalide');
        }
        const data = await res.json();
        couponCode.value = code;
        couponDiscount.value = data.discount;
    }

    async function checkout() {
        const auth = useAuthStore();
        if (!auth.isAuthenticated) {
            throw new Error('Auth required');
        }
        const res = await fetch('/api/orders', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'include',
            body: JSON.stringify({ items: items.value, couponCode: couponCode.value }),
        });
        if (!res.ok) throw new Error('Checkout failed');
        const order = await res.json();
        clear();
        return order;
    }

    function clear() {
        items.value = [];
        couponCode.value = null;
        couponDiscount.value = 0;
    }

    return {
        items, couponCode, couponDiscount,
        itemCount, subtotal, shipping, discount, total, isEmpty,
        addItem, removeItem, updateQuantity, applyCoupon, checkout, clear,
    };
}, {
    persist: true, // localStorage par défaut
});

3. Consommation dans un composant — autocomplete TypeScript complète

<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';
import { useAuthStore } from '@/stores/auth';

const cart = useCartStore();
const auth = useAuthStore();

// storeToRefs préserve la réactivité à la déstructuration
const { items, itemCount, total, isEmpty } = storeToRefs(cart);
// Les actions sont directement déstructurables
const { addItem, removeItem, checkout } = cart;
const { displayName, isAuthenticated } = storeToRefs(auth);
</script>

<template>
    <header>
        <p v-if="isAuthenticated">Bonjour {{ displayName }}</p>
        <span>Panier ({{ itemCount }})</span>
    </header>

    <main v-if="!isEmpty">
        <ul>
            <li v-for="item in items" :key="item.productId">
                {{ item.name }} × {{ item.quantity }} = {{ (item.price * item.quantity).toFixed(2) }}€
                <button @click="removeItem(item.productId)">Retirer</button>
            </li>
        </ul>
        <p>Total : {{ total.toFixed(2) }}€</p>
        <button @click="checkout" :disabled="!isAuthenticated">Commander</button>
    </main>
    <p v-else>Panier vide</p>
</template>
Avantages mesurables de ce setup Pinia :
  • Autocomplete TypeScript complète : chaque getter, action et state est typé automatiquement par Pinia
  • DevTools Vue : inspect en temps réel + time-travel debugging gratuit
  • Persistance partielle : uniquement user persisté côté auth, le panier complet côté cart
  • Composition de stores : useCartStore.checkout() appelle useAuthStore() sans couplage
  • Bundle ~5 KB gzipped pour ces 2 stores complets — bien moins que Redux + middleware équivalent

Pour aller plus loin avec Pinia + SSR Nuxt 3, voir le guide Nuxt 3 SSR. Pour les tests Pinia avec Vitest + Vue Test Utils, voir aussi le guide des composables qui couvre les patterns de tests communs.

Questions fréquentes

Pinia est le successeur officiel de Vuex, recommandé par l'équipe Vue depuis Vue 3. Les différences principales : Pinia supprime les mutations (on modifie l'état directement dans les actions), offre une API plus simple et intuitive, supporte nativement TypeScript sans configuration supplémentaire, et propose une architecture modulaire où chaque store est une fonction indépendante (style Composition API). Pinia réduit le boilerplate de 60-70 % par rapport à Vuex 4. Les DevTools Vue supportent pleinement Pinia avec le time-travel debugging. Pour les nouveaux projets Vue 3, Pinia est le choix recommandé ; Vuex 5 a été abandonné au profit de Pinia.

Définissez un store avec `defineStore` : `export const useCounterStore = defineStore("counter", () => { const count = ref(0); const doubled = computed(() => count.value * 2); function increment() { count.value++ } return { count, doubled, increment } })`. Dans un composant, utilisez `const store = useCounterStore()` et accédez directement à `store.count`, `store.doubled`, `store.increment()`. Pinia supporte deux syntaxes : Options Store (proche de Vuex) et Setup Store (Composition API, recommandée). Avec `storeToRefs(store)`, déstructurez les propriétés réactives sans perdre la réactivité : `const { count, doubled } = storeToRefs(store)`.

Utilisez le plugin `pinia-plugin-persistedstate` : `npm install pinia-plugin-persistedstate`. Configurez-le dans `main.ts` : `const pinia = createPinia(); pinia.use(piniaPluginPersistedstate)`. Activez la persistance par store : `export const useUserStore = defineStore("user", () => { ... }, { persist: true })`. Par défaut, le store est sauvegardé dans `localStorage` sous la clé du store ID. Personnalisez avec `persist: { storage: sessionStorage, paths: ["token", "userId"] }` pour ne persister que certaines propriétés et utiliser sessionStorage. Cette solution est idéale pour conserver l'authentification, les préférences utilisateur et les paniers d'achat entre les sessions.

Dans les tests, créez une instance Pinia fraîche avec `setActivePinia(createPinia())` dans le `beforeEach`. Importez et instanciez votre store : `const store = useCounterStore()`. Modifiez l'état directement : `store.count = 5` ou via les actions : `store.increment()`. Vérifiez avec `expect(store.count).toBe(6)`. Pour les actions asynchrones avec des appels API, mockez les dépendances avec `vi.mock()`. Pinia offre `$patch` pour modifier plusieurs propriétés atomiquement dans les tests : `store.$patch({ count: 10, name: "test" })`. Cette approche de test est 3x plus simple que les tests Vuex qui nécessitaient des mocks de mutations complexes.

Destructurer un store Pinia (const { count } = useCounter()) perd la réactivité — count devient une valeur figée, pas un ref qui suit les changements. storeToRefs(store) crée un objet de refs pour chaque propriété state/getter, préservant la réactivité au moment de la destructuration. const { count, doubled } = storeToRefs(store) puis utiliser count.value dans la classe ou {{ count }} dans le template. Les actions ne nécessitent PAS storeToRefs car ce sont des fonctions stables — on les destructure directement.

Définissez l'action comme une fonction async dans le setup : async function fetchUser(id: string) { loading.value = true; try { user.value = await api.getUser(id); } catch (e) { error.value = e; } finally { loading.value = false; } }. L'appelant peut await store.fetchUser(id) et catch les erreurs. Pour les requêtes lourdes, combinez avec un AbortController pour annuler si le composant se démonte. Pour les états de chargement multiples, créez un Map indexé par opération plutôt qu'un seul flag loading.

Un store peut en importer un autre — useAuthStore() peut être appelé dans la définition d'un autre store. Exemple : useCartStore utilise useAuthStore() pour vérifier l'utilisateur connecté et associer le panier. Attention aux dépendances cycliques : si A importe B et B importe A, lazy-loadez via une fonction (() => useStoreB()) au lieu d'un import direct. Alternative : créer un troisième store partagé qui contient l'état commun, ou utiliser provide/inject pour les dépendances scopées.

Setup Store (recommandé). Syntaxe Composition API pure : defineStore("counter", () => { const count = ref(0); const double = computed(() => count.value * 2); function inc() { count.value++; } return { count, double, inc }; }). Avantages : (1) Plus flexible — peut utiliser tous les composables Vue, (2) Typage TypeScript natif sans tricks, (3) Cohérent avec le reste de l'app en Composition API. Option Store (legacy) : defineStore("counter", { state: () => ({ count: 0 }), getters: { double: (s) => s.count * 2 }, actions: { inc() { this.count++; } } }). Plus proche de Vuex 4 — utile uniquement pour la migration. Verdict 2026 : Setup Store par défaut, Option Store uniquement si migration Vuex.

Trois règles pour éviter les bugs de mismatch hydratation. (1) Ne JAMAIS lire localStorage/sessionStorage directement dans le state initial — utiliser pinia-plugin-persistedstate avec storage: persistedState.cookiesWithOptions() pour partager côté serveur. (2) Initialiser les données async via useFetch/useAsyncData dans un asyncSetup ou via store.fetchAction() depuis app.vue — pas dans le state() directement. (3) Pour les valeurs dépendantes du navigateur (window, navigator), utiliser process.client check ou onMounted(). Pattern Nuxt 3 : créer un plugin auth.ts qui hydrate le store avec les cookies au démarrage de l'app — pas de FOUC (Flash of Unauthenticated Content). Voir le guide Nuxt 3 SSR.

Pinia (Vue) : la référence officielle Vue 3, syntaxe Composition API, ~1 KB gzipped, DevTools intégrés, typage TS natif. Zustand (React) : équivalent React de Pinia — minimaliste, hooks-based, sans Provider obligatoire. Redux Toolkit (React) : plus structuré, Redux DevTools time-travel, middleware étendu, mais plus de boilerplate. Différence philosophique : Pinia/Zustand = "le moins de code possible", Redux = "discipline pour grandes équipes". Pour un projet Vue : Pinia, point. Pour React : Zustand pour 80 % des cas, Redux Toolkit si vous avez besoin du time-travel debugging ou d'une discipline stricte. Voir le comparatif Zustand vs Redux Toolkit.

Partager