Front-end angularforall.com

- 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.

Partager