Skip to content

5. L'état partagé

5.1. Exemple 15 : un état partagé avec Pinia

Dans une application, certaines données intéressent de nombreux composants : l'utilisateur connecté, le panier... L'exemple 11 les partageait avec [provide] / [inject], l'exemple 14 avec une [ref] de module. [Pinia], la bibliothèque officielle de [Vue.js] pour l'état partagé, organise cela en stores (magasins), et les rend visibles dans l'extension [Vue.js devtools].

On l'installe dans [main.ts] :

// =========================================================================
// 15-pinia/src/main.ts
// -------------------------------------------------------------------------
// app.use(createPinia()) : on installe Pinia, la bibliothèque officielle de
// gestion d'état PARTAGÉ de Vue.js. Les "stores" (magasins) qu'on définit
// ensuite sont accessibles depuis n'importe quel composant.
// =========================================================================

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App.vue';

createApp(App).use(createPinia()).mount('#app');

Un premier store, le panier :

// =========================================================================
// 15-pinia/src/stores/panier.ts - un STORE Pinia
// -------------------------------------------------------------------------
// defineStore('panier', () => { ... }) : un store écrit comme un composable
// (style "setup") :
//   - les ref sont l'ÉTAT du store ;
//   - les computed, ses GETTERS (valeurs calculées) ;
//   - les fonctions, ses ACTIONS (ce qui modifie l'état).
// Différence avec un composable (exemple 11) : le store n'existe qu'en UN
// exemplaire pour toute l'application. Tous les composants qui appellent
// usePanierStore() reçoivent le MÊME panier. Il vit tant que l'application
// vit : il survit au retrait d'un composant... mais pas au rechargement de
// la page (F5), qui relance l'application.
// =========================================================================

import { computed, ref } from 'vue';
import { defineStore } from 'pinia';

export interface Article {
  libelle: string;
  prix: number;
}

export const usePanierStore = defineStore('panier', () => {
  const articles = ref<Article[]>([]);

  const nombre = computed(() => articles.value.length);
  const total = computed(() => articles.value.reduce((s, a) => s + a.prix, 0));

  function ajouter(article: Article): void {
    articles.value.push(article);
  }
  function vider(): void {
    articles.value = [];
  }

  // tout ce qui est renvoyé fait partie du store
  return { articles, nombre, total, ajouter, vider };
});
  • lignes 24-39 : [defineStore(nom, fonction)] définit un store dans le style « setup », comme un composable : les [ref] sont son état, les [computed] ses getters, les fonctions ses actions ; il expose ce que la fonction renvoie ;
  • la différence avec un composable : le store n'existe qu'en un exemplaire pour toute l'application. Tous les composants qui appellent [usePanierStore()] reçoivent le MÊME panier. Il vit tant que l'application vit : il survit au retrait d'un composant... mais pas au rechargement de la page (F5), qui relance l'application.

Un second store survit, lui, au rechargement :

// =========================================================================
// 15-pinia/src/stores/preferences.ts - un store qui survit à F5
// -------------------------------------------------------------------------
// Pour qu'une donnée survive au rechargement de la page, ou à la fermeture
// du navigateur, il faut la ranger DANS le navigateur : localStorage (un
// petit dictionnaire clé -> chaîne, propre à chaque site).
//   - au démarrage, le store relit localStorage ;
//   - watch(..., { deep: true }) réécrit localStorage à chaque changement.
// On n'y range JAMAIS de donnée sensible (mot de passe, jeton) : tout script
// de la page peut lire localStorage.
// =========================================================================

import { ref, watch } from 'vue';
import { defineStore } from 'pinia';

const CLE = 'exemple15.preferences';

interface Preferences {
  sombre: boolean;
  taille: number; // taille du texte, en px
}

function lire(): Preferences {
  try {
    return { sombre: false, taille: 16, ...JSON.parse(localStorage.getItem(CLE) ?? '{}') };
  } catch {
    return { sombre: false, taille: 16 }; // contenu illisible : valeurs par défaut
  }
}

export const usePreferencesStore = defineStore('preferences', () => {
  const preferences = ref<Preferences>(lire());

  watch(preferences, (p) => localStorage.setItem(CLE, JSON.stringify(p)), { deep: true });

  return { preferences };
});
  • lignes 23-29 : au démarrage, le store relit [localStorage], un petit dictionnaire clé / chaîne que le navigateur conserve pour chaque site, même après sa fermeture. Un contenu illisible donne les valeurs par défaut ;
  • ligne 34 : [{ deep: true }] : le [watch] réagit aussi aux changements des propriétés de l'objet. Chaque changement est réécrit dans [localStorage].

On n'y range JAMAIS de donnée sensible (mot de passe, jeton) : tout script exécuté dans la page peut lire [localStorage].

Les composants :

<!-- 15-pinia/src/components/Boutique.vue - AJOUTE au panier partagé -->
<script setup lang="ts">
import { usePanierStore, type Article } from '../stores/panier';

const panier = usePanierStore();
const produits: Article[] = [
  { libelle: 'Consultation', prix: 30 },
  { libelle: 'Vaccin', prix: 12.5 },
];
</script>

<template>
  <div class="card p-3 h-100">
    <h2 class="h6">Boutique</h2>
    <button v-for="p in produits" :key="p.libelle" class="btn btn-sm btn-outline-primary mb-2" @click="panier.ajouter(p)">
      + {{ p.libelle }} ({{ p.prix }} €)
    </button>
  </div>
</template>
  • ligne 5 : le store s'utilise comme un objet réactif : [panier.ajouter(p)] ;
<!--
  15-pinia/src/components/ResumePanier.vue - LIT le même panier
  storeToRefs : pour DÉSTRUCTURER un store sans perdre la réactivité
  (const { nombre } = panier donnerait une simple copie, figée).
-->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { usePanierStore } from '../stores/panier';

const panier = usePanierStore();
const { nombre, total } = storeToRefs(panier);
</script>

<template>
  <div class="card p-3 h-100">
    <h2 class="h6">Panier</h2>
    <p class="mb-2">{{ nombre }} article(s), {{ total }} €</p>
    <!-- les actions, elles, se déstructurent ou s'appellent directement -->
    <button class="btn btn-sm btn-outline-danger align-self-start" :disabled="nombre === 0" @click="panier.vider()">Vider</button>
  </div>
</template>
  • ligne 11 : pour déstructurer un store sans perdre la réactivité. [const { nombre } = panier] donnerait une simple copie, figée à la valeur du moment ; [storeToRefs] donne des [ref] reliées au store. Les actions, elles, se déstructurent ou s'appellent directement.
<!-- 15-pinia/src/components/CompteurLocal.vue - un état LOCAL, qui disparaît avec le composant -->
<script setup lang="ts">
import { ref } from 'vue';
const clics = ref(0);
</script>

<template>
  <div class="card p-3 h-100">
    <h2 class="h6">État local</h2>
    <button class="btn btn-sm btn-outline-secondary align-self-start" @click="clics++">{{ clics }} clic(s)</button>
  </div>
</template>
<!--
  =========================================================================
  15-pinia/src/App.vue - trois "portées" de données côté client
  -------------------------------------------------------------------------
  Même question que l'exemple 23 du cours NestJS (portée application,
  requête, client), transposée dans le navigateur :
    - l'état LOCAL d'un composant (CompteurLocal) disparaît avec lui ;
    - l'état d'un STORE (le panier) est partagé par tous les composants et
      survit à leur retrait - mais pas à F5 ;
    - l'état rangé dans localStorage (les préférences) survit à F5 et à la
      fermeture du navigateur.
  Décochez puis recochez la case, puis rechargez la page : comparez.
  =========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
import { usePreferencesStore } from './stores/preferences';
import Boutique from './components/Boutique.vue';
import ResumePanier from './components/ResumePanier.vue';
import CompteurLocal from './components/CompteurLocal.vue';

const prefs = usePreferencesStore();
const zoneVisible = ref(true);
</script>

<template>
  <div :class="{ 'bg-dark text-light': prefs.preferences.sombre }" :style="{ fontSize: prefs.preferences.taille + 'px' }" class="min-vh-100">
    <div class="container py-4">
      <h1 class="h3 mb-3">Exemple 15 - un état partagé avec Pinia</h1>
      <div class="d-flex flex-wrap gap-4 mb-3">
        <label class="form-check-label"><input v-model="prefs.preferences.sombre" type="checkbox" class="form-check-input"> thème sombre</label>
        <label>taille du texte <input v-model.number="prefs.preferences.taille" type="range" min="12" max="22"> {{ prefs.preferences.taille }} px</label>
        <label class="form-check-label"><input v-model="zoneVisible" type="checkbox" class="form-check-input"> afficher la zone</label>
      </div>
      <div v-if="zoneVisible" class="row g-3 text-body">
        <div class="col-md-4"><Boutique /></div>
        <div class="col-md-4"><ResumePanier /></div>
        <div class="col-md-4"><CompteurLocal /></div>
      </div>
      <p class="mt-3 mb-2">Hors de la zone, un second résumé du MÊME panier (il reste là quand la zone disparaît) :</p>
      <div class="text-body"><ResumePanier /></div>
    </div>
  </div>
</template>
  • ligne 35 : décocher « afficher la zone » retire les trois composants de la page ;
  • ligne 41 : un second résumé du même panier, hors de la zone.

Même question que l'exemple 23 du cours [NestJS] (portées application, requête, client), transposée dans le navigateur :

Donnée
Où ?
Durée de vie
le compteur de CompteurLocal
l'état local du composant
disparaît avec le composant
le panier
un store Pinia
toute l'application, jusqu'à F5
les préférences
un store Pinia + localStorage
survit à F5 et à la fermeture du navigateur

Deux articles ajoutés et deux clics :

Image

La zone retirée et le thème sombre choisi : le panier est toujours là ; si l'on réaffiche la zone, le compteur local est revenu à zéro :

Image