Cours Vue.js · Module 3: Réactivité

Réactivité profonde vs superficielle

5 min de lecture
Dans cette leçon5

Vue propose différents niveaux de réactivité - comme différentes couches de capteurs dans un réacteur.

Toutes les quelques secondes, la télémétrie du réacteur envoie un lot de milliers de lectures. Tu remplaces le lot entier à chaque fois, et pourtant la réactivité par défaut enveloppe dans un Proxy chaque objet imbriqué que tu touches et suit chaque lecture séparément. C'est comme monter un capteur sur chaque boulon de la cargaison, alors que tout ce qui t'intéresse, c'est de savoir si une nouvelle caisse est arrivée. Pour ces situations, Vue propose des versions superficielles.

Réactivité profonde (par défaut)

Par défaut, la réactivité est profonde : ref() contenant un objet et reactive() suivent les changements à chaque niveau d'imbrication, tableaux compris.

1import { ref, reactive } from 'vue'
2
3// ref avec un objet - réactivité profonde
4const missionControl = ref({
5  station: 'NOVA LAB',
6  modules: [
7    { id: 1, name: 'Oxygen Generator' },
8    { id: 2, name: 'Water Recycler' }
9  ]
10})
11
12// Les changements à n'importe quel niveau sont réactifs
13missionControl.value.station = 'NOVA LAB Alpha'
14missionControl.value.modules[0].name = 'Oxygen Generator Prime'
15missionControl.value.modules.push({ id: 3, name: 'Power Core' })
16// Tout ce qui précède déclenchera une mise à jour du panneau de contrôle

Chacun des trois changements rafraîchit le panneau, car ref() a transmis l'objet à reactive(), qui enveloppe les objets imbriqués dès que tu y accèdes. Pour les données d'interface classiques, c'est pratique et assez rapide.

shallowRef() - Réactivité superficielle

shallowRef() stocke la valeur sans la transformer en Proxy. Seul l'accès à .value est réactif, c'est-à-dire le remplacement de tout le contenu :

1import { shallowRef } from 'vue'
2
3const missionControl = shallowRef({
4  station: 'NOVA LAB',
5  modules: [{ id: 1, name: 'Oxygen Generator' }]
6})
7
8// Seul le remplacement de .value est réactif
9missionControl.value.station = 'Nouveau nom' // NE déclenchera PAS de mise à jour !
10missionControl.value.modules.push({ id: 2, name: 'Water Recycler' }) // NE déclenchera PAS de mise à jour !
11
12// CECI fonctionnera - remplacer l'objet entier
13missionControl.value = {
14  station: 'NOVA LAB Beta',
15  modules: []
16}

Les deux premiers changements sont bel et bien enregistrés en mémoire - seul l'écran ne change pas, car Vue n'en sait rien. Ils n'apparaîtront qu'au prochain rendu déclenché par autre chose. Pour modifier un seul champ d'un shallowRef nommé sensorData, construis un nouvel objet avec l'opérateur de décomposition :

1sensorData.value = {
2  ...sensorData.value,
3  temperature: 3000
4}

Le nouvel objet passe par le setter de .value, donc la vue se rafraîchit, et l'ancien lot de données reste intact. Copier un énorme tableau juste pour ajouter un élément peut cependant être du gaspillage. Dans ce cas, tu modifies le contenu sur place et tu appelles triggerRef(), qui exécute manuellement tout ce qui dépend du ref superficiel :

1import { shallowRef, triggerRef } from 'vue'
2
3const readings = shallowRef([])
4
5readings.value.push({ id: 1, temperature: 2500 }) // la vue ne le sait pas encore
6triggerRef(readings) // forcer la mise à jour

Après triggerRef, la vue se rafraîchit une fois. La responsabilité de cet appel te revient - oublie-le, et le panneau affichera d'anciennes données.

shallowReactive()

shallowReactive() ne rend réactives que les propriétés du premier niveau. Les objets imbriqués sont stockés tels quels, donc Vue ne voit pas leur intérieur :

1import { shallowReactive } from 'vue'
2
3const missionControl = shallowReactive({
4  station: 'NOVA LAB',
5  location: {
6    planet: 'Mars',
7    year: 2087
8  }
9})
10
11missionControl.station = 'Nouvelle station' // Réactif - niveau 1
12missionControl.location.planet = 'Europa' // PAS réactif - niveau 2
13
14// Tu dois remplacer l'objet location entier
15missionControl.location = { planet: 'Europa', year: 2087 }

Remplacer location est une modification d'une propriété du premier niveau, c'est pourquoi cela fonctionne. La documentation avertit : utilise les structures superficielles uniquement comme état racine d'un composant, et ne les imbrique pas dans des objets profondément réactifs, car cela crée un arbre au comportement incohérent.

Quand utiliser shallow ?

Les versions superficielles sont un outil d'optimisation, pas un nouveau choix par défaut :

SituationÀ utiliser
Gros objets provenant d'une APIshallowRef
Bibliothèques externesshallowRef
Optimisation des performancesshallow*
Données d'interface classiquesref/reactive

Mon conseil : commence avec ref et reactive, et réserve les versions superficielles aux grosses données immuables ou à un état géré par une bibliothèque externe.

Exemple pratique

Le panneau de surveillance récupère les données des capteurs et remplace toujours le lot entier. Le template affiche le nom du système et un bouton de rafraîchissement :

1<template>
2  <div class="sensor-display" style="background: #0a0e27; color: #00ff88;">
3    <h2>{{ sensorData.systemName }}</h2>
4    <button @click="refreshData">Rafraîchir les lectures</button>
5  </div>
6</template>

Le script garde les données dans un shallowRef, et une fonction asynchrone en récupère de nouvelles avec fetch, l'API du navigateur pour les requêtes réseau :

1<script setup>
2import { shallowRef } from 'vue'
3
4// Données de capteur volumineuses - réactivité superficielle
5const sensorData = shallowRef({
6  systemName: 'Reactor Monitoring',
7  readings: [] // Des milliers de lectures de capteurs
8})
9
10async function refreshData() {
11  const response = await fetch('/api/sensors')
12  // Remplacer l'objet entier - réactif
13  sensorData.value = await response.json()
14}
15</script>

await attend la réponse, puis l'affectation à .value rafraîchit le panneau. Vue n'enveloppe pas les milliers de lectures, car personne ne les modifie une par une. Dans la prochaine leçon, tu extrairas des propriétés isolées d'un objet réactif sans rompre la connexion.

Retiens : la réactivité profonde, c'est un capteur sur chaque boulon, et la superficielle, un seul capteur sur l'écoutille - il ne remarque que l'échange de toute la cargaison ou un signal manuel triggerRef.

Code de cette leçon : App.vue
1<script setup>
2import { reactive, toRef, toRefs } from 'vue'
3
4// toRef() et toRefs() - Extraction de propriétés réactives
5// Système de gestion des membres d'équipage du NOVA LAB
6
7const crewMember = reactive({
8  id: 'CREW-001',
9  name: 'Dr. Sarah Chen',
10  role: 'Chief Engineer',
11  status: 'ACTIVE',
12  stats: {
13    health: 95,
14    energy: 80,
15    morale: 90
16  },
17  certifications: ['Reactor Operations', 'EVA Specialist', 'Life Support'],
18  missions: 12
19})
20
21// toRef() - crée un ref vers une seule propriété
22// Reste réactive et synchronisée avec l'objet original
23const memberName = toRef(crewMember, 'name')
24const memberStatus = toRef(crewMember, 'status')
25const memberHealth = toRef(crewMember.stats, 'health')
26
27// toRefs() - crée des refs vers toutes les propriétés d'un objet
28// Utile pour la déstructuration
29const { role, missions } = toRefs(crewMember)
30
31// Deuxième membre d'équipage pour la démonstration
32const secondCrew = reactive({
33  id: 'CREW-002',
34  name: 'Cpt. Marcus Rodriguez',
35  role: 'Mission Commander',
36  status: 'ACTIVE',
37  stats: {
38    health: 88,
39    energy: 75,
40    morale: 85
41  },
42  certifications: ['Command', 'Navigation', 'Emergency Response'],
43  missions: 25
44})
45
46// On utilise toRefs() pour la déstructuration
47const {
48  name: secondName,
49  role: secondRole,
50  missions: secondMissions,
51  status: secondStatus
52} = toRefs(secondCrew)
53
54// Fonctions de modification
55function updateStatus(newStatus) {
56  // Modification via toRef - change l'objet original
57  memberStatus.value = newStatus
58}
59
60function decreaseHealth() {
61  // Modification via toRef - affecte l'objet original
62  if (memberHealth.value > 0) {
63    memberHealth.value -= 10
64  }
65}
66
67function increaseMissions() {
68  // Modification via toRefs - affecte l'objet original
69  missions.value++
70}
71
72function changeRole(newRole) {
73  // Modification via toRefs - affecte l'objet original
74  role.value = newRole
75}
76
77function promoteSecondCrew() {
78  secondMissions.value++
79  if (secondMissions.value > 30) {
80    secondRole.value = 'Senior Commander'
81    secondStatus.value = 'VETERAN'
82  }
83}
84</script>
85
86<template>
87  <div class="crew-management">
88    <div class="header">
89      <h1>GESTION DE L'ÉQUIPAGE - NOVA LAB</h1>
90      <p class="subtitle">toRef() et toRefs() - Extraction de propriétés réactives</p>
91    </div>
92
93    <div class="crew-grid">
94      <!-- Premier membre d'équipage - toRef() -->
95      <div class="crew-card">
96        <div class="crew-header">
97          <h2>{{ crewMember.name }}</h2>
98          <span :class="'status-badge status-' + crewMember.status.toLowerCase()">
99            {{ crewMember.status }}
100          </span>
101        </div>
102
103        <div class="crew-info">
104          <p><strong>ID :</strong> {{ crewMember.id }}</p>
105          <p><strong>Rôle :</strong> {{ crewMember.role }}</p>
106          <p><strong>Missions :</strong> {{ crewMember.missions }}</p>
107        </div>
108
109        <div class="stats-section">
110          <h3>Statistiques</h3>
111          <div class="stat-bar">
112            <label>Santé</label>
113            <div class="progress">
114              <div class="progress-fill health" :style="{ width: crewMember.stats.health + '%' }">
115                {{ crewMember.stats.health }}%
116              </div>
117            </div>
118          </div>
119          <div class="stat-bar">
120            <label>Énergie</label>
121            <div class="progress">
122              <div class="progress-fill energy" :style="{ width: crewMember.stats.energy + '%' }">
123                {{ crewMember.stats.energy }}%
124              </div>
125            </div>
126          </div>
127          <div class="stat-bar">
128            <label>Morale</label>
129            <div class="progress">
130              <div class="progress-fill morale" :style="{ width: crewMember.stats.morale + '%' }">
131                {{ crewMember.stats.morale }}%
132              </div>
133            </div>
134          </div>
135        </div>
136
137        <div class="certifications">
138          <h3>Certifications</h3>
139          <div class="cert-tags">
140            <span v-for="cert in crewMember.certifications" :key="cert" class="cert-tag">
141              {{ cert }}
142            </span>
143          </div>
144        </div>
145
146        <div class="controls">
147          <button @click="updateStatus('ON_MISSION')" class="btn btn-small">
148            Envoyer en mission
149          </button>
150          <button @click="updateStatus('RESTING')" class="btn btn-small">
151            Repos
152          </button>
153          <button @click="decreaseHealth()" class="btn btn-small btn-warning">
154            Simuler une blessure
155          </button>
156          <button @click="increaseMissions()" class="btn btn-small btn-success">
157            Ajouter une mission
158          </button>
159        </div>
160
161        <div class="code-box">
162          <pre>// toRef() - une seule propriété
163const name = toRef(crewMember, 'name')
164const health = toRef(crewMember.stats, 'health')
165
166// Modification
167health.value -= 10</pre>
168        </div>
169      </div>
170
171      <!-- Second crew member - toRefs() -->
172      <div class="crew-card">
173        <div class="crew-header">
174          <h2>{{ secondCrew.name }}</h2>
175          <span :class="'status-badge status-' + secondCrew.status.toLowerCase()">
176            {{ secondCrew.status }}
177          </span>
178        </div>
179
180        <div class="crew-info">
181          <p><strong>ID :</strong> {{ secondCrew.id }}</p>
182          <p><strong>Rôle :</strong> {{ secondRole }}</p>
183          <p><strong>Missions :</strong> {{ secondMissions }}</p>
184        </div>
185
186        <div class="stats-section">
187          <h3>Statistiques</h3>
188          <div class="stat-bar">
189            <label>Santé</label>
190            <div class="progress">
191              <div class="progress-fill health" :style="{ width: secondCrew.stats.health + '%' }">
192                {{ secondCrew.stats.health }}%
193              </div>
194            </div>
195          </div>
196          <div class="stat-bar">
197            <label>Énergie</label>
198            <div class="progress">
199              <div class="progress-fill energy" :style="{ width: secondCrew.stats.energy + '%' }">
200                {{ secondCrew.stats.energy }}%
201              </div>
202            </div>
203          </div>
204          <div class="stat-bar">
205            <label>Morale</label>
206            <div class="progress">
207              <div class="progress-fill morale" :style="{ width: secondCrew.stats.morale + '%' }">
208                {{ secondCrew.stats.morale }}%
209              </div>
210            </div>
211          </div>
212        </div>
213
214        <div class="certifications">
215          <h3>Certifications</h3>
216          <div class="cert-tags">
217            <span v-for="cert in secondCrew.certifications" :key="cert" class="cert-tag">
218              {{ cert }}
219            </span>
220          </div>
221        </div>
222
223        <div class="controls">
224          <button @click="promoteSecondCrew()" class="btn btn-small btn-success">
225            Promouvoir
226          </button>
227          <button @click="changeRole('Flight Director')" class="btn btn-small">
228            Changer de rôle
229          </button>
230          <button @click="secondStatus.value = 'ACTIVE'" class="btn btn-small">
231            Activer
232          </button>
233        </div>
234
235        <div class="code-box">
236          <pre>// toRefs() - toutes les propriétés
237const { role, missions, status } = toRefs(secondCrew)
238
239// Modification
240missions.value++
241role.value = 'Senior Commander'</pre>
242        </div>
243      </div>
244    </div>
245
246    <div class="info-box">
247      <h3>Différence entre toRef() et toRefs()</h3>
248      <div class="comparison-grid">
249        <div class="comparison-item">
250          <h4>toRef(object, 'key')</h4>
251          <ul>
252            <li>Crée un ref vers une <strong>seule</strong> propriété</li>
253            <li>Utile quand on n'a besoin que d'une valeur</li>
254            <li>Maintient la réactivité avec l'objet original</li>
255            <li>Exemple : <code>const name = toRef(user, 'name')</code></li>
256          </ul>
257        </div>
258        <div class="comparison-item">
259          <h4>toRefs(object)</h4>
260          <ul>
261            <li>Crée des refs vers <strong>toutes</strong> les propriétés</li>
262            <li>Utile pour la déstructuration</li>
263            <li>Chaque propriété devient un ref</li>
264            <li>Exemple : <code>const { name, age } = toRefs(user)</code></li>
265          </ul>
266        </div>
267      </div>
268      <div class="warning-box">
269        <p><strong>Important :</strong> une déstructuration classique perdra la réactivité !</p>
270        <code>const { name } = crewMember // Non réactif</code><br>
271        <code>const { name } = toRefs(crewMember) // Réactif</code>
272      </div>
273    </div>
274  </div>
275</template>
276
277<style scoped>
278.crew-management {
279  min-height: 100vh;
280  background: #0a0e27;
281  color: #e0e0e0;
282  padding: 2rem;
283  font-family: 'Courier New', monospace;
284}
285
286.header {
287  text-align: center;
288  margin-bottom: 2rem;
289  border-bottom: 2px solid #00ff88;
290  padding-bottom: 1rem;
291}
292
293.header h1 {
294  color: #00ff88;
295  font-size: 2rem;
296  margin: 0;
297  text-shadow: 0 0 10px #00ff88;
298}
299
300.subtitle {
301  color: #00b4d8;
302  margin: 0.5rem 0 0 0;
303}
304
305.crew-grid {
306  display: grid;
307  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
308  gap: 2rem;
309  margin-bottom: 2rem;
310}
311
312.crew-card {
313  background: #1a1f3a;
314  padding: 1.5rem;
315  border-radius: 8px;
316  border: 1px solid #00b4d8;
317}
318
319.crew-header {
320  display: flex;
321  justify-content: space-between;
322  align-items: center;
323  margin-bottom: 1rem;
324  padding-bottom: 1rem;
325  border-bottom: 1px solid #333;
326}
327
328.crew-header h2 {
329  color: #00ff88;
330  margin: 0;
331  font-size: 1.3rem;
332}
333
334.status-badge {
335  padding: 0.3rem 0.8rem;
336  border-radius: 20px;
337  font-size: 0.8rem;
338  font-weight: bold;
339}
340
341.status-active {
342  background: #00ff88;
343  color: #0a0e27;
344}
345
346.status-on_mission {
347  background: #00b4d8;
348  color: #0a0e27;
349}
350
351.status-resting {
352  background: #ffaa00;
353  color: #0a0e27;
354}
355
356.status-veteran {
357  background: linear-gradient(135deg, #ffd700, #ffaa00);
358  color: #0a0e27;
359}
360
361.crew-info p {
362  margin: 0.5rem 0;
363  color: #ccc;
364}
365
366.stats-section {
367  margin: 1.5rem 0;
368}
369
370.stats-section h3 {
371  color: #00b4d8;
372  font-size: 1rem;
373  margin-bottom: 1rem;
374}
375
376.stat-bar {
377  margin-bottom: 1rem;
378}
379
380.stat-bar label {
381  display: block;
382  font-size: 0.9rem;
383  color: #888;
384  margin-bottom: 0.3rem;
385}
386
387.progress {
388  height: 24px;
389  background: #0a0e27;
390  border-radius: 4px;
391  overflow: hidden;
392}
393
394.progress-fill {
395  height: 100%;
396  display: flex;
397  align-items: center;
398  justify-content: center;
399  color: #0a0e27;
400  font-weight: bold;
401  font-size: 0.85rem;
402  transition: width 0.3s ease;
403}
404
405.progress-fill.health {
406  background: linear-gradient(90deg, #ff4444, #ff6666);
407}
408
409.progress-fill.energy {
410  background: linear-gradient(90deg, #00b4d8, #00d4ff);
411}
412
413.progress-fill.morale {
414  background: linear-gradient(90deg, #00ff88, #44ffaa);
415}
416
417.certifications {
418  margin: 1.5rem 0;
419}
420
421.certifications h3 {
422  color: #00b4d8;
423  font-size: 1rem;
424  margin-bottom: 0.8rem;
425}
426
427.cert-tags {
428  display: flex;
429  flex-wrap: wrap;
430  gap: 0.5rem;
431}
432
433.cert-tag {
434  background: #0a0e27;
435  padding: 0.4rem 0.8rem;
436  border-radius: 4px;
437  border: 1px solid #00ff88;
438  color: #00ff88;
439  font-size: 0.85rem;
440}
441
442.controls {
443  display: flex;
444  flex-wrap: wrap;
445  gap: 0.5rem;
446  margin: 1rem 0;
447}
448
449.btn {
450  padding: 0.6rem 1rem;
451  font-size: 0.9rem;
452  border: none;
453  border-radius: 6px;
454  cursor: pointer;
455  font-weight: bold;
456  transition: all 0.3s ease;
457  font-family: 'Courier New', monospace;
458  background: #00b4d8;
459  color: #0a0e27;
460}
461
462.btn-small {
463  font-size: 0.85rem;
464  padding: 0.5rem 0.8rem;
465}
466
467.btn-success {
468  background: linear-gradient(135deg, #00ff88, #00dd77);
469}
470
471.btn-warning {
472  background: linear-gradient(135deg, #ff6666, #ff4444);
473  color: white;
474}
475
476.btn:hover {
477  transform: scale(1.05);
478  box-shadow: 0 0 15px currentColor;
479}
480
481.code-box {
482  background: #0a0e27;
483  padding: 1rem;
484  border-radius: 4px;
485  border-left: 3px solid #00ff88;
486  margin-top: 1rem;
487}
488
489.code-box pre {
490  margin: 0;
491  color: #00ff88;
492  font-size: 0.85rem;
493  overflow-x: auto;
494}
495
496.info-box {
497  background: #1a1f3a;
498  padding: 1.5rem;
499  border-radius: 8px;
500  border-left: 4px solid #00ff88;
501}
502
503.info-box h3 {
504  color: #00ff88;
505  margin-top: 0;
506}
507
508.comparison-grid {
509  display: grid;
510  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
511  gap: 1.5rem;
512  margin: 1rem 0;
513}
514
515.comparison-item h4 {
516  color: #00b4d8;
517  margin-top: 0;
518}
519
520.comparison-item ul {
521  padding-left: 1.5rem;
522  color: #ccc;
523}
524
525.comparison-item li {
526  margin: 0.5rem 0;
527}
528
529.comparison-item code {
530  background: #0a0e27;
531  padding: 0.2rem 0.5rem;
532  border-radius: 3px;
533  color: #00ff88;
534}
535
536.warning-box {
537  background: #2a1f1a;
538  padding: 1rem;
539  border-radius: 4px;
540  border-left: 3px solid #ffaa00;
541  margin-top: 1rem;
542}
543
544.warning-box code {
545  background: #0a0e27;
546  padding: 0.3rem 0.6rem;
547  border-radius: 3px;
548  color: #00ff88;
549  display: inline-block;
550  margin: 0.2rem 0;
551}
552</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. Quelle est la profondeur de réactivité par défaut de ref() et reactive() ?

  2. 2. En quoi shallowRef() diffère-t-il de ref() ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un shallowRef avec un objet et démontre la différence - modifier une propriété imbriquée ne déclenchera pas de re-rendu.

  • Tri horizontal

    Range dans le bon ordre les éléments pour mettre correctement à jour un shallowRef.

  • Clique dans l'ordre

    Range les fonctions réactives de la réactivité la plus profonde à la plus superficielle.

  • Éditeur de code

    Crée un objet shallowReactive et montre la différence entre modifier une propriété de premier niveau et une propriété imbriquée.