Cours Vue.js · Module 9: Composables et VueUse

Modèles de conception réactifs

8 min de lecture
Dans cette leçon4

Au centre de contrôle de NOVA LAB, chaque système doit être fiable et prévisible. Le problème apparaît quand les modules doivent coopérer : le panneau du thème et le panneau des notifications doivent voir les mêmes données, un capteur doit alerter plusieurs systèmes à la fois, et une expérience ne doit pas passer directement de "interrompue" à "terminée". Les modèles de conception associés à la réactivité de Vue 3 permettent de construire de tels systèmes - comme les protocoles d'ingénierie éprouvés d'une station spatiale.

Reactive Singleton

Parfois, on a besoin d'une seule instance d'état partagée - comme l'ordinateur central de la station qu'utilisent tous les modules. L'astuce tient à l'endroit où l'état est créé : on crée l'objet reactive en dehors de la fonction du composable, au niveau du module JavaScript, donc il existe exactement une fois. La fonction readonly en renvoie une version en lecture seule :

1import { reactive, readonly } from 'vue'
2
3// État créé UNE SEULE FOIS, en dehors de la fonction
4const globalState = reactive({
5  currentUser: null,
6  theme: 'dark',
7  locale: 'pl',
8  notifications: []
9})
10
11// Le composable retourne le même état à chaque fois
12export function useGlobalStore() {
13  function setUser(user) {
14    globalState.currentUser = user
15  }
16
17  function setTheme(theme) {
18    globalState.theme = theme
19  }
20
21  function addNotification(msg) {
22    globalState.notifications.push({
23      id: Date.now(),
24      message: msg,
25      read: false
26    })
27  }
28
29  // readonly empêche la modification directe
30  return {
31    state: readonly(globalState),
32    setUser,
33    setTheme,
34    addNotification
35  }
36}

Les modifications passent uniquement par les méthodes, et une tentative d'écrire state.theme = 'light' dans un composant se termine par un avertissement dans la console, sans que la valeur change. L'utilisation dans n'importe quel composant ressemble à ceci :

1<!-- N'importe quel composant - le même état -->
2<script setup>
3import { useGlobalStore } from './composables/useGlobalStore'
4
5// Les deux composants voient les mêmes données
6const { state, addNotification } = useGlobalStore()
7</script>
8
9<template>
10  <p>Thème : {{ state.theme }}</p>
11  <p>Notifications : {{ state.notifications.length }}</p>
12</template>

La différence clé : l'état est défini en dehors de la fonction du composable, donc chaque appel à useGlobalStore() retourne une référence vers le même objet. Une réserve : avec le rendu côté serveur (SSR), un tel état serait partagé entre tous les utilisateurs. C'est pourquoi, dans les grandes applications, ce rôle est repris par Pinia, que tu découvriras dans la base de données centrale.

Event Bus avec réactivité

Vue 3 a supprimé les méthodes $on et $off de l'instance de composant, sur lesquelles on construisait un event bus global en Vue 2. Nous pouvons toutefois créer un système d'événements réactif - comme le réseau de communication de la station NOVA LAB. La map listeners associe à chaque nom d'événement une liste de fonctions de traitement, et la ref lastEvent mémorise le dernier signal. Les deux sont créés en dehors de la fonction, comme dans le singleton, pour que tous les composants émettent sur le même réseau :

1import { ref } from 'vue'
2
3// Un seul réseau pour toute l'application - en dehors de la fonction
4const lastEvent = ref(null)
5const listeners = new Map()
6
7function useEventBus() {
8  function emit(eventName, payload) {
9    lastEvent.value = { name: eventName, payload, timestamp: Date.now() }
10
11    const handlers = listeners.get(eventName) || []
12    handlers.forEach(handler => handler(payload))
13  }
14
15  function on(eventName, handler) {
16    if (!listeners.has(eventName)) {
17      listeners.set(eventName, [])
18    }
19    listeners.get(eventName).push(handler)
20
21    // Retourne une fonction de désabonnement
22    return () => {
23      const handlers = listeners.get(eventName)
24      const index = handlers.indexOf(handler)
25      if (index > -1) handlers.splice(index, 1)
26    }
27  }
28
29  return { lastEvent, emit, on }
30}

Si la map était créée à l'intérieur de la fonction, chaque composant aurait son propre réseau privé, et les signaux d'un module n'atteindraient jamais un autre. La méthode on retourne une fonction qui retire le gestionnaire de la liste, et le composant doit l'appeler lors de son démontage :

1<script setup>
2import { onUnmounted } from 'vue'
3
4const { emit, on } = useEventBus()
5
6// Abonnement avec nettoyage automatique
7const unsubscribe = on('sensor-alert', (data) => {
8  console.log('Alerte du capteur :', data)
9})
10
11onUnmounted(() => {
12  unsubscribe() // Important ! Évite les fuites mémoire
13})
14
15// Émission d'un événement
16emit('sensor-alert', { sensorId: 'T-01', value: 45 })
17</script>

Sans unsubscribe, un composant démonté resterait pour toujours dans la map et continuerait à réagir aux alarmes - une fuite mémoire classique.

State Machine avec ref et computed

Les machines à états sont un modèle idéal pour gérer un état complexe - comme les protocoles de sécurité de la station, où chaque état possède des transitions précisément définies. La machine garde l'état courant dans une ref et calcule la liste des événements autorisés avec computed :

1import { ref, computed } from 'vue'
2
3function useStateMachine(config) {
4  const currentState = ref(config.initial)
5
6  const availableTransitions = computed(() => {
7    const stateConfig = config.states[currentState.value]
8    return stateConfig ? Object.keys(stateConfig.on || {}) : []
9  })
10
11  function transition(event) {
12    const stateConfig = config.states[currentState.value]
13    if (!stateConfig || !stateConfig.on || !stateConfig.on[event]) {
14      console.warn(
15        `Transition interdite : ${currentState.value} -> ${event}`
16      )
17      return false
18    }
19
20    const nextState = stateConfig.on[event]
21    currentState.value = nextState
22    return true
23  }
24
25  function canTransition(event) {
26    return availableTransitions.value.includes(event)
27  }
28
29  return { currentState, availableTransitions, transition, canTransition }
30}

La méthode transition rejette un événement que l'état courant ne prévoit pas et retourne false au lieu d'abîmer les données. Toute la connaissance des transitions se trouve dans l'objet de configuration, où le champ on de chaque état associe un événement à un état cible :

1<script setup>
2const experimentMachine = useStateMachine({
3  initial: 'idle',
4  states: {
5    idle: {
6      on: { START: 'running', CONFIGURE: 'configuring' }
7    },
8    configuring: {
9      on: { SAVE: 'idle', CANCEL: 'idle' }
10    },
11    running: {
12      on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' }
13    },
14    paused: {
15      on: { RESUME: 'running', ABORT: 'failed' }
16    },
17    completed: {
18      on: { RESET: 'idle' }
19    },
20    failed: {
21      on: { RESET: 'idle', RETRY: 'running' }
22    }
23  }
24})
25
26// Utilisé dans le template
27// experimentMachine.currentState.value => 'idle'
28// experimentMachine.transition('START') => l'état passe à 'running'
29// experimentMachine.canTransition('PAUSE') => true (nous sommes maintenant dans 'running')
30</script>

Depuis l'état idle, impossible de déclencher PAUSE, donc tu peux désactiver le bouton de pause avec un simple :disabled="!experimentMachine.canTransition('PAUSE')".

Reactive Observer Pattern

Le modèle observateur avec la réactivité de Vue - comme un système de surveillance où chaque capteur peut avoir de nombreux observateurs. Chaque abonné est un watch distinct, et la fonction retournée par watch l'arrête à la demande :

1import { ref, watch, readonly } from 'vue'
2
3function useObservable(initialValue) {
4  const value = ref(initialValue)
5  const history = ref([])
6  const observers = ref(0)
7
8  // Chaque watch est un nouvel observateur
9  function subscribe(callback) {
10    observers.value++
11    const unwatch = watch(value, (newVal, oldVal) => {
12      callback({ newVal, oldVal, timestamp: Date.now() })
13    })
14
15    return () => {
16      unwatch()
17      observers.value--
18    }
19  }
20
21  function setValue(newValue) {
22    history.value.push({
23      from: value.value,
24      to: newValue,
25      timestamp: Date.now()
26    })
27    if (history.value.length > 100) history.value.shift()
28    value.value = newValue
29  }
30
31  return {
32    value: readonly(value),
33    history: readonly(history),
34    observers,
35    subscribe,
36    setValue
37  }
38}

L'historique garde au maximum les 100 derniers changements, et on expose la valeur et l'historique en lecture seule. Dans le composant, il y a un piège : le template ne déballe automatiquement que les refs de premier niveau, pas les refs cachées dans un objet ordinaire. C'est pourquoi on les extrait par déstructuration :

1<script setup>
2import { onUnmounted } from 'vue'
3
4const temperature = useObservable(22)
5// On remonte les refs au premier niveau pour que le template les déballe
6const { value: currentTemp, history, observers } = temperature
7
8// Observateur 1 - alarme
9const unsub1 = temperature.subscribe(({ newVal }) => {
10  if (newVal > 40) console.warn('Température critique !')
11})
12
13// Observateur 2 - journalisation
14const unsub2 = temperature.subscribe(({ newVal, oldVal }) => {
15  console.log(`Temp: ${oldVal} -> ${newVal}`)
16})
17
18onUnmounted(() => {
19  unsub1()
20  unsub2()
21})
22
23// Changer la valeur notifie tous les observateurs
24temperature.setValue(25)
25</script>
26
27<template>
28  <p>Température : {{ currentTemp }}°C</p>
29  <p>Observateurs actifs : {{ observers }}</p>
30  <p>Historique des changements : {{ history.length }}</p>
31</template>

Le panneau affiche 25°C, deux observateurs et un changement dans l'historique, et la console écrit "Temp: 22 -> 25". Si tu écrivais temperature.history.length dans le template, tu verrais un emplacement vide, car la ref imbriquée ne serait pas déballée.

Mon conseil : avant de recourir à un event bus, vérifie si les props et les emits, provide/inject ou un store partagé suffisent. La documentation de Vue déconseille un bus global dans la plupart des situations, car le flux de données devient invisible. VueUse, que tu découvriras dans la prochaine leçon, propose d'ailleurs un useEventBus tout prêt. Dans l'éditeur ci-dessous, le singleton change le thème, la machine à états fait passer une expérience par ses états successifs, et la température observée notifie ses abonnés.

Retiens : un bon modèle est un protocole de station - il dit qui peut modifier l'état, quand, et qui doit en être averti.

Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, watch, readonly } from 'vue'
3
4// ===== SINGLETON RÉACTIF =====
5const globalState = {
6  theme: ref('dark'),
7  locale: ref('fr'),
8  notifications: ref([])
9}
10
11function useGlobalStore() {
12  function setTheme(t) { globalState.theme.value = t }
13  function addNotification(msg) {
14    globalState.notifications.value.push({ id: Date.now(), message: msg })
15  }
16  return {
17    theme: globalState.theme,
18    locale: globalState.locale,
19    notifications: globalState.notifications,
20    setTheme,
21    addNotification
22  }
23}
24
25// ===== STATE MACHINE =====
26function useStateMachine(config) {
27  const currentState = ref(config.initial)
28
29  const availableTransitions = computed(() => {
30    const sc = config.states[currentState.value]
31    return sc ? Object.keys(sc.on || {}) : []
32  })
33
34  function transition(event) {
35    const sc = config.states[currentState.value]
36    if (!sc || !sc.on || !sc.on[event]) return false
37    currentState.value = sc.on[event]
38    return true
39  }
40
41  function canTransition(event) {
42    return availableTransitions.value.includes(event)
43  }
44
45  return { currentState, availableTransitions, transition, canTransition }
46}
47
48// ===== OBSERVER RÉACTIF =====
49function useObservable(initialValue) {
50  const value = ref(initialValue)
51  const history = ref([])
52  const observerCount = ref(0)
53
54  function subscribe(callback) {
55    observerCount.value++
56    const unwatch = watch(value, (newVal, oldVal) => {
57      callback({ newVal, oldVal })
58    })
59    return () => { unwatch(); observerCount.value-- }
60  }
61
62  function setValue(newValue) {
63    history.value.push({ from: value.value, to: newValue, time: new Date().toLocaleTimeString() })
64    if (history.value.length > 15) history.value.shift()
65    value.value = newValue
66  }
67
68  return { value, history, observerCount, subscribe, setValue }
69}
70
71// === Instances ===
72const store = useGlobalStore()
73
74const experiment = useStateMachine({
75  initial: 'idle',
76  states: {
77    idle: { on: { START: 'running', CONFIGURE: 'configuring' } },
78    configuring: { on: { SAVE: 'idle', CANCEL: 'idle' } },
79    running: { on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' } },
80    paused: { on: { RESUME: 'running', ABORT: 'failed' } },
81    completed: { on: { RESET: 'idle' } },
82    failed: { on: { RESET: 'idle', RETRY: 'running' } }
83  }
84})
85
86const temperature = useObservable(22)
87
88// Abonner les observers
89const alerts = ref([])
90temperature.subscribe(({ newVal }) => {
91  if (newVal > 35) alerts.value.unshift('ALERT: Temp ' + newVal + '°C!')
92  if (alerts.value.length > 5) alerts.value.pop()
93})
94temperature.subscribe(({ newVal, oldVal }) => {
95  store.addNotification('Temp: ' + oldVal + ' -> ' + newVal)
96})
97</script>
98
99<template>
100  <div class="lab" :class="store.theme.value">
101    <h1>Modèles réactifs - NOVA LAB</h1>
102
103    <section>
104      <h2>Store Singleton</h2>
105      <p>Thème : {{ store.theme.value }} | Locale : {{ store.locale.value }}</p>
106      <button @click="store.setTheme(store.theme.value === 'dark' ? 'light' : 'dark')">Basculer le thème</button>
107      <div class="notifs">
108        <div v-for="n in store.notifications.value.slice(-5)" :key="n.id" class="notif">{{ n.message }}</div>
109      </div>
110    </section>
111
112    <section>
113      <h2>Machine à états - Expérience</h2>
114      <div class="state-display">{{ experiment.currentState.value.toUpperCase() }}</div>
115      <div class="transitions">
116        <button v-for="t in experiment.availableTransitions.value" :key="t"
117          @click="experiment.transition(t)" class="trans-btn">
118          {{ t }}
119        </button>
120      </div>
121      <p v-if="!experiment.availableTransitions.value.length" class="info">Aucune transition (état terminal)</p>
122    </section>
123
124    <section>
125      <h2>Observable - Température</h2>
126      <p>Valeur : {{ temperature.value.value }}°C | Observers : {{ temperature.observerCount.value }}</p>
127      <div class="temp-controls">
128        <button @click="temperature.setValue(temperature.value.value - 5)">-5°C</button>
129        <button @click="temperature.setValue(temperature.value.value + 5)">+5°C</button>
130        <button @click="temperature.setValue(Math.round(Math.random() * 50))">Random</button>
131      </div>
132      <div class="history">
133        <div v-for="(h, i) in temperature.history.value.slice(-8)" :key="i" class="hist-item">
134          {{ h.time }}: {{ h.from }}°C -> {{ h.to }}°C
135        </div>
136      </div>
137      <div v-if="alerts.length" class="alerts">
138        <div v-for="(a, i) in alerts" :key="i" class="alert">{{ a }}</div>
139      </div>
140    </section>
141  </div>
142</template>
143
144<style scoped>
145.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; transition: all 0.3s; }
146.lab.light { background: #f0f0f5; color: #1a1a3f; }
147h1 { color: #00ff88; text-align: center; }
148h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
149section { margin: 1.5rem 0; }
150button { background: #00b4d8; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
151button:hover { background: #00ff88; color: #0a0e27; }
152.state-display { font-size: 2rem; font-weight: bold; color: #00ff88; text-align: center; padding: 1rem; background: rgba(0,255,136,0.1); border: 2px solid #00ff88; border-radius: 8px; margin: 1rem 0; }
153.transitions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
154.trans-btn { background: #333; }
155.trans-btn:hover { background: #00b4d8; }
156.temp-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.history { background: rgba(0,0,0,0.3); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
158.hist-item { font-size: 0.8rem; color: #aaa; padding: 0.2rem 0; }
159.notifs { margin-top: 0.5rem; }
160.notif { font-size: 0.8rem; color: #00b4d8; padding: 0.2rem 0; }
161.alerts { margin-top: 0.5rem; }
162.alert { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; padding: 0.3rem 0.6rem; margin: 0.2rem 0; font-size: 0.85rem; color: #ff4444; }
163.info { color: #666; font-style: italic; }
164</style>

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que signifie le modèle Singleton réactif dans les composables ?

  2. 2. Quel est le principal avantage du modèle Machine à états avec ref et computed ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Construis useStateMachine avec currentState (ref), availableTransitions (computed), transition() et canTransition(). La configuration en paramètre.

  • Tri vertical

    Ordonne les étapes de création d'un composable Singleton réactif :

  • Clique dans l'ordre

    Ordonne les étapes pour s'abonner à un événement et l'émettre :

  • Éditeur de code

    Construis useObservable avec value (ref), history (ref), subscribe() qui retourne unsubscribe, setValue() et un compteur d'observers.

  • Tri horizontal

    Ordonne l'appel de transition d'état dans une machine à états :