Les observateurs

Introduction

Jusqu'ici, nous avons découvert deux grands principes de la réactivité dans Vue. D'abord, une donnée réactive met automatiquement à jour l'affichage lorsqu'elle change. Ensuite, avec les propriétés calculées (computed()), nous pouvons produire des valeurs dérivées à partir de ces données, recalculées uniquement lorsque leurs dépendances sont modifiées, et sans déclencher d'effet de bord.

Parfois, nous ne voulons pas calculer une valeur, mais exécuter une action lorsque des données réactives spécifiques changent. Cette action peut être asynchrone (ex. : une requête fetch()) ou avoir un effet externe (ex. : enregistrer une donnée dans le localStorage). C'est précisément le rôle des observateurs, comme watch() et watchEffect(), ainsi que de quelques variantes plus spécialisées.

À la différence des propriétés calculées, un observateur n'a pas pour objectif de retourner une valeur. Il sert uniquement à déclencher un effet de bord, autrement dit une action réalisée en dehors du calcul, comme la sauvegarde de données, l'envoi d'une requête HTTP, une animation ou une opération de journalisation. Cette distinction est importante, car computed() calcule et fournit une valeur à afficher ou à réutiliser, tandis que watch() surveille les changements de données et exécute une action en conséquence.

Voici quelques situations courantes où les observateurs sont particulièrement utiles :

Structure et fonctionnement de watch()

La fonction watch() permet de surveiller une donnée réactive et d'exécuter du code lorsqu'elle change. Elle ne retourne aucune valeur, car son objectif est de réaliser une action en réponse à une modification, comme sauvegarder une information, envoyer une requête ou déclencher une animation JavaScript. Ce comportement simplifie le code et évite la répétition d'actions inutiles ou d'animations incohérentes.

La fonction watch() définit deux paramètres obligatoires et accepte un troisième paramètre optionnel :

  1. La/les Sources réactives à observer.
  2. La fonction de rappel déclenchée à chaque fois que la source observée change.
  3. Les options de configuration (facultatif).

L'exemple suivant affiche un message dans la console chaque fois que la valeur du compteur change. La fonction watch() surveille la donnée réactive et déclenche automatiquement le code défini dans son rappel à chaque incrémentation ou décrémentation.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const compteur = ref(0);

                const incrementer = () =>
                {
                    compteur.value++
                }

                const decrementer = () =>
                {
                    compteur.value--
                }

                // À chaque changement de "compteur" un message est automatiquement affiché dans la console.
                watch(compteur, () => 
                {
                    console.log('Le compteur vient d\'être modifié');
                })
                </script>

                <template>
                    

Compteur : {{ compteur }}

<button @click="incrementer">Incrémenter</button> <button @click="decrementer">Décrémenter</button> </template>

Types de sources réactives observables

La fonction watch() peut surveiller différents types de sources réactives, comme des valeurs créées avec ref(), des objets ou tableaux créés avec reactive() ou encore des propriétés calculées définies avec computed(). Son comportement varie selon la nature de la source observée.

Observer ref()

Par défaut, lorsqu'une source réactive a été initialisée avec ref(), watch() ne se déclenche que lorsque la source est réassignée.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const compteur = ref(0);

                // Le rappel est exécuté à chaque réassignation de "compteur".
                watch(compteur, () => 
                {
                    console.log('Le compteur vient d\'être modifié');
                });

                const incrementer = () => { compteur.value++ }
                </script>

                <template>
                    <p>Compteur : {{ compteur }}</p>
                    <button @click="incrementer">Incrémenter</button>
                </template>
            

Si cette source contenait un objet ou un tableau, les mutations internes (ex.: l'ajout, la suppression ou la modification d'un élément) ne sont pas observées par défaut.

Pour permettre à Vue d'observer également les mutations internes d'une source créée avec ref(), il faut activer l'option deep:true dans le troisième argument de watch().


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const compteurs = ref([0, 0]);

                const incrementer = (index) =>
                {
                    compteurs.value[index]++;
                }

                // Grâce à l'option "deep:true", les mutations internes du tableau
                // sont observées et déclenchent le rappel.
                watch(compteurs, () => 
                {
                    console.log('L\'un des compteurs vient d\'être modifié');
                }, { deep: true });
                </script>

                <template>
                    <p>Compteur 1 : {{ compteurs[0] }}</p>
                    <p>Compteur 2 : {{ compteurs[1] }}</p>
                    <button @click="incrementer(0)">Incrémenter le premier compteur</button>
                    <button @click="incrementer(1)">Incrémenter le second compteur</button>
                </template>
            

Si vous effectuez le test en retirant { deep: true }, watch() n'observera plus les mutations internes du tableau, et le message ne s'affichera plus dans la console. Le rappel ne se déclenchera alors que si la référence compteurs est remplacée par un nouveau tableau.

Observer reactive()

Lorsqu'on observe un objet ou un tableau créé avec reactive(), watch() se déclenche automatiquement à chaque mutation interne, comme l'ajout, la suppression ou la modification d'une propriété. L'option deep:true est inutile dans ce cas.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { reactive, watch } from 'vue';

                const compteurs = reactive([0, 0]);

                const incrementer = (index) =>
                {
                    compteurs[index]++;
                }

                // Avec "reactive()" les mutations internes du tableau
                // sont automatiquement observées et déclenchent le rappel,
                // elles n'ont donc pas besoin de configurer "deep" à "true".
                watch(compteurs, () => 
                {
                    console.log('L\'un des compteurs vient d\'être modifié');
                });
                </script>

                <template>
                    <p>Compteur 1 : {{ compteurs[0] }}</p>
                    <p>Compteur 2 : {{ compteurs[1] }}</p>
                    <button @click="incrementer(0)">Incrémenter le premier compteur</button>
                    <button @click="incrementer(1)">Incrémenter le second compteur</button>
                </template>
            

Pour rappel, une variable créée avec reactive() ne peut pas être réassignée directement, car cela ferait perdre la réactivité de l'objet.

Observer computed()

Lorsqu'on observe une propriété calculée créée avec computed(), le watch() se déclenche automatiquement dès que le résultat de cette propriété change, c'est-à-dire lorsque l'une de ses dépendances réactives est modifiée.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, computed, watch } from 'vue';

                const a = ref(0);
                const b = ref(0);

                // Propriété calculée dépendant de "a" et "b".
                const somme = computed(() =>
                {
                    const somme = a.value + b.value;

                    // Retourner la valeur de la somme si celle-ci est un nombre.
                    // Sinon, retourner un message d'erreur.
                    return (isNaN(somme) ? 'calcul impossigle' : somme);
                });

                // Le rappel est exécuté chaque fois que "somme" change.
                watch(somme, () =>
                {
                    console.log('La valeur de "somme" vient de changer');
                });

                </script>

                <template>
                    <p>a : {{ a }} + b : {{ b }} = somme : {{ somme }}</p>
                    <div>
                        <label for="premierNombre">valeur de a :</label>
                        <input id="premierNombre" type="text" v-model.number="a">
                    </div>
                    <div>
                        <label for="secondNombre">valeur de b :</label>
                        <input id="secondNombre" type="text" v-model.number="b">
                    </div>
                </template>
            

Comportement de computed() avec watch()

Une propriété calculée créée avec computed() se comporte comme un ref() en lecture seule. Le watch() se déclenche dès que la valeur retournée par la propriété calculée change.

Si la propriété calculée retourne une valeur primitive (nombre, chaîne, booléen), chaque modification de l'une de ses dépendances modifie son résultat et déclenche automatiquement le watch().

Si la propriété calculée retourne un objet ou un tableau, les mutations internes ne déclenchent pas le watch() par défaut.

Pour observer également les mutations internes d'un objet ou d'un tableau retourné par une propriété calculée, il faut activer l'option deep:true.

Observer plusieurs sources réactives

La fonction watch() peut également surveiller plusieurs sources réactives liées à une seule fonction de rappel. Pour cela, on place les différentes sources à observer dans un tableau. Si l'une d'elles est modifiée, la fonction de rappel est automatiquement exécutée.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const compteur1 = ref(0);
                const compteur2 = ref(0);

                // Le rappel est exécuté dès que "compteur1" ou "compteur2" change.
                watch([compteur1, compteur2], () =>
                {
                    console.log('Une des deux valeurs a été modifiée');
                });

                const incrementerpremierCompteur = () => 
                {
                    compteur1.value++;
                };

                const decrementerSecondCompteur = () => 
                {
                    compteur2.value--;
                };
                </script>

                <template>
                    <p>Premier compteur : {{ compteur1 }} | Second compteur : {{ compteur2 }}</p>
                    <button @click="() => incrementerpremierCompteur()">Incrémenter le premier compteur</button>
                    <button @click="() => decrementerSecondCompteur()">Décrémenter le second compteur</button>
                </template>
            

Dans cet exemple, la fonction watch() est déclenchée dès que la valeur de compteur1 ou de compteur2 change. Cette approche permet de regrouper plusieurs observations dans un seul watch() au lieu d'en créer plusieurs séparément.

Gestion des anciennes et nouvelles valeurs de la source dans la fonction de rappel

La fonction de rappel passée à watch() peut recevoir deux paramètres qui correspondent aux valeurs de la source observée avant et après le changement. Le premier paramètre contient la nouvelle valeur et le second l'ancienne valeur. Ces informations permettent de comparer deux états successifs d'une donnée et d'adapter le comportement du programme en conséquence, par exemple exécuter un code différent selon que la valeur a augmenté ou diminué.

Récupérer la nouvelle valeur de la source

L'exemple suivant montre un champ de texte dont le contenu est conservé automatiquement, même après la fermeture ou le rechargement de la page. Toute modification saisie par l'utilisateur est sauvegardée instantanément, de sorte que la note reste disponible lors de la prochaine visite.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                // On récupère la valeur sauvegardée dans le localStorage au démarrage.
                const note = ref(localStorage.getItem('note') || '');

                // On observe la variable "note" et on met à jour le localStorage à chaque modification.
                watch(note, (nouvelleValeurDeNote) =>
                {
                    localStorage.setItem('note', nouvelleValeurDeNote);
                });
                </script>

                <template>
                    <label for="note">Votre note :</label>
                    <textarea id="note" v-model="note" rows="5" cols="30"></textarea>
                </template>
            

Dans le code, la donnée réactive note est surveillée par watch(). Chaque fois que sa valeur change, Vue transmet la nouvelle à la fonction de rappel. Cette dernière enregistre la nouvelle valeur dans le localStorage, ce qui maintient la note synchronisée entre l'application et le stockage local sans nécessiter de rechargement de page.

Récupérer l'ancienne et la nouvelle valeur de la source

L'exemple suivant affiche la température actuelle et un message indiquant si elle augmente, diminue ou reste stable. Lorsqu'on modifie la valeur dans le champ numérique, le message s'adapte automatiquement en fonction de l'évolution.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const temperature = ref(20);
                const message = ref('');

                // Observation des changements de "temperature".
                watch(temperature, (nouvelleValeurTemperature, ancienneValeurTemperature) =>
                {
                    if (nouvelleValeurTemperature > ancienneValeurTemperature)
                    {
                        message.value = 'La température augmente';
                    }
                    else if (nouvelleValeurTemperature < ancienneValeurTemperature)
                    {
                        message.value = 'La température baisse';
                    }
                    else
                    {
                        message.value = 'La température est stable';
                    }
                });
                </script>

                <template>
                    <p>Température actuelle : {{ temperature }}°C</p>
                    <p>{{ message }}</p>
                    <input type="number" v-model.number="temperature">
                </template>
            

Dans ce code, la donnée réactive temperature est surveillée par watch(). À chaque modification, Vue compare la nouvelle valeur et l'ancienne pour déterminer le sens du changement. Le message affiché s'ajuste ensuite automatiquement, illustrant comment l'ancienne valeur peut servir à comprendre l'évolution d'une donnée plutôt que de simplement constater qu'elle a changé.

Récupérer les anciennes et nouvelles valeurs de plusieurs sources

Lorsqu'une fonction watch() observe plusieurs sources réactives regroupées dans un tableau, Vue transmet au rappel deux tableaux. Le premier contient les nouvelles valeurs et le second les anciennes valeurs, dans le même ordre que les sources observées. Ce mécanisme permet de comparer chaque paire de valeurs et d'exécuter un traitement différent selon la source qui a changé.

Grâce à cette approche, il devient possible d'exécuter des actions conditionnelles précises, comme identifier quelle variable a été modifiée, ajuster un calcul, mettre à jour une partie spécifique de l'interface, ou encore synchroniser plusieurs données entre elles.

L'exemple suivant affiche deux compteurs indépendants. Lorsqu'on modifie l'un d'eux, un message apparaît dans la console pour indiquer quel compteur a changé.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                const compteur1 = ref(0);
                const compteur2 = ref(0);

                // Le rappel reçoit deux tableaux : [nouvellesValeurs] et [anciennesValeurs]
                // On peut déterminer quelle source réactive a été modifiée
                // en comparant chaque nouvelle valeur avec son ancienne valeur correspondante.
                // Si les valeurs sont différentes, cela signifie que la source a été modifiée.
                watch([compteur1, compteur2], ([nouveau1, nouveau2], [ancien1, ancien2]) =>
                {
                    const numeroDeCompteurModifie = nouveau1 !== ancien1 ? 'premier' : 'second';
                    console.log(`Le ${numeroDeCompteurModifie} compteur vient d'être modifié`);
                });

                // Fonctions de mise à jour des compteurs
                const incrementerPremierCompteur = () =>
                {
                    compteur1.value++;
                };

                const decrementerSecondCompteur = () =>
                {
                    compteur2.value--;
                };
                </script>

                <template>
                    <p>Premier compteur : {{ compteur1 }} | Second compteur : {{ compteur2 }}</p>

                    <!-- 
                        Chaque bouton déclenche une fonction différente.  
                        Vue détecte automatiquement quel compteur a changé et transmet l'ensemble 
                        des anciennes et nouvelles valeurs au rappel du watch().
                    -->
                    <button @click="incrementerPremierCompteur">Incrémenter le premier compteur</button>
                    <button @click="decrementerSecondCompteur">Décrémenter le second compteur</button>
                </template>
            

Dans ce code, les deux compteurs sont observés simultanément par une seule fonction watch(). À chaque modification, Vue transmet deux tableaux au rappel. Le premier contient les nouvelles valeurs des sources observées et le second contient leurs anciennes valeurs, dans le même ordre. Pour chaque source, Vue compare la nouvelle valeur et l'ancienne valeur correspondante. Si ces deux valeurs sont différentes, cela signifie que cette source a été modifiée.

En revanche, la source qui n'a pas changé conservera exactement la même valeur dans les deux tableaux (nouvelle et ancienne). Cette comparaison permet d'identifier précisément quelle donnée a évolué et de déclencher un traitement ciblé, comme ici l'affichage d'un message dans la console.

Configurer le comportement des observateurs

Le troisième paramètre de watch() est un objet d'options qui permet d'ajuster la manière dont la surveillance s'effectue. Par défaut, Vue utilise { immediate: false, deep: false, flush: 'pre' }.

Voici un aperçu rapide de ce que ces options permettent :

Voyons maintenant chacune de ces options plus en détail.

L'option immediate

L'option immediate exécute la fonction de rappel dès la création du watch(), sans attendre un premier changement. Elle est utile lorsqu'une action doit se produire immédiatement au montage, comme une initialisation ou une synchronisation basée sur la valeur actuelle.


                <script setup>
                // On n'oublie pas d'importer la fonction "watch()" !!!
                import { ref, watch } from 'vue';

                // Langue sélectionnée par l'utilisateur.
                const langue = ref('fr');

                // Message affiché à l'écran en fonction de la langue sélectionnée.
                const message = ref('');

                // Observateur qui réagit au changement de langue.
                // L'option "immediate" réglée sur true permet d'exécuter immédiatement
                // la fonction de rappel au montage, sans attendre une première modification.
                watch(langue, (nouvelleLangue) =>
                {
                    // Crée un objet Date représentant le moment actuel.
                    const date = new Date();

                    // Met à jour le message selon la langue choisie.
                    // La méthode "toLocaleString()" formate la date selon la langue passée en argument.
                    // Exemple : "fr-FR" → "23/10/2025, 21:35:12" (format français)
                    //           "en-US" → "10/23/2025, 9:35:12 PM" (format américain)
                    //           "es-ES" → "23/10/2025 21:35:12" (format espagnol)
                    switch (nouvelleLangue)
                    {
                        case 'fr':
                            message.value = 'Date actuelle : ' + date.toLocaleString('fr-FR');
                            break;

                        case 'en':
                            message.value = 'Current date: ' + date.toLocaleString('en-US');
                            break;

                        case 'es':
                            message.value = 'Fecha actual: ' + date.toLocaleString('es-ES');
                            break;

                        default:
                            message.value = 'Langue non reconnue.';
                            break;
                    };
                },
                { immediate: true });
                </script>

                <template>
                    <p>{{ message }}</p>

                    <label>Choisir la langue :</label>
                    <select v-model="langue">
                        <option value="fr">Français</option>
                        <option value="en">Anglais</option>
                        <option value="es">Espagnol</option>
                    </select>
                </template>
            

L'option deep

L'option deep indique si watch() doit réagir uniquement aux réassignations ou également aux mutations internes d'un objet ou d'un tableau (ex.: ajout, suppression ou modification d'une propriété).

Par défaut, deep vaut false. Dans ce mode, watch() ne se déclenche que lorsque la source observée est réassignée à une nouvelle référence. Les mutations internes d'un objet ou d'un tableau ne sont donc pas prises en compte.

Ce comportement concerne les sources créées avec ref() ou computed() lorsque celles-ci contiennent ou retournent un objet ou un tableau. Les structures créées avec reactive(), en revanche, déclenchent déjà le rappel automatiquement lors de chaque mutation interne. Dans ce cas, il est inutile d'activer deep: true.

L'option deep doit toutefois être utilisée avec parcimonie, car elle augmente le nombre d'éléments surveillés et peut ralentir les performances lorsque les structures observées sont complexes ou modifiées très souvent.

Ce sujet a déjà été présenté dans le chapitre Structure et fonctionnement de watch, où un exemple concret d'utilisation illustre son fonctionnement.

L'option flush

L'option flush précise le moment d'exécution du rappel de watch() par rapport au cycle de mise à jour de Vue. Selon le réglage choisi, le rappel peut être exécuté à l'intérieur du cycle (avant ou après la mise à jour du DOM) ou en dehors du cycle, de manière immédiate à chaque modification de la source réactive. Ce comportement influence la réactivité perçue ainsi que la façon dont les changements successifs sont regroupés ou traités séparément.

Le paramètre flush peut prendre trois valeurs possibles :

L'exemple suivant illustre la différence entre les trois modes de déclenchement du paramètre flush. Un compteur est affiché à l'écran et, à chaque clic sur le bouton, sa valeur est incrémentée deux fois. Trois observateurs watch() sont configurés avec des modes de déclenchement différents. Chacun enregistre, dans un petit journal réactif, la nouvelle valeur du compteur ainsi que le contenu effectivement affiché dans la balise <h2>. Cela permet de visualiser que le mode 'pre' s'exécute avant la mise à jour du DOM (il lit encore l'ancien contenu affiché), tandis que le mode 'post' s'exécute après le rendu (il lit le DOM mis à jour). Le mode 'sync', lui, réagit immédiatement à chaque modification de la donnée, sans attendre la planification du rendu.


                <script setup>
                import { ref, watch, useTemplateRef } from 'vue';

                const compteur = ref(0);

                // useTemplateRef('titre') : Référence "template" vers l'élément <h2>.
                const titre = useTemplateRef('titre');

                // Journal réactif affiché dans le template pour voir l'ordre et le contenu des callbacks.
                const logs = ref([]);

                // Fonction utilitaire pour centraliser l'écriture dans le journal.
                // Elle lit le DOM réel (titre.value.textContent) afin de montrer ce qui est VRAIMENT affiché à l'écran
                // au moment précis où chaque watcher s'exécute.
                const log = (tag, nouvelleValeur, ancienneValeur) =>
                {
                    // On lit le texte actuellement affiché dans la balise <h2> du DOM réel.
                    // La méthode trim() supprime les espaces éventuels au début et à la fin de la chaîne.
                    //
                    // L'opérateur optionnel "?" (appelé "optional chaining") permet de vérifier que
                    // l'élément existe avant d'y accéder.
                    // Cela évite une erreur si la référence n'est pas encore liée au DOM.
                    const domText = titre.value?.textContent;

                    // On ajoute une entrée dans le tableau "logs".
                    // Chaque ligne indique :
                    //  - le type de "flush" exécuté (SYNC, PRE ou POST)
                    //  - les anciennes et nouvelles valeurs du compteur
                    //  - le contenu du DOM observé au moment précis du déclenchement.
                    logs.value.push(
                        `<b>${tag}</b><br>Évolution du compteur : ${ancienneValeur}→${nouvelleValeur}<br>DOM : "${domText}"`
                    );
                };

                // flush: 'post' : s'exécute après la mise à jour du DOM réel.
                //    Le DOM est déjà synchronisé avec la donnée au moment du callback.
                watch(compteur, (n, a) => log('POST', n, a), { flush: 'post' });

                // flush: 'pre' : s'exécute avant la mise à jour du DOM réel (après que Vue a planifié le rendu).
                //    La donnée réactive est déjà à jour, MAIS le DOM n'est pas encore rafraîchi.
                watch(compteur, (n, a) => log('PRE', n, a),  { flush: 'pre' });

                // flush: 'sync' : s'exécute immédiatement à CHAQUE mutation (pas de regroupement).
                watch(compteur, (n, a) => log('SYNC', n, a), { flush: 'sync' });

                // Démo : Nettoyer le journal puis incrémenter deux fois dans le même "tick".
                // pour bien voir la différence :
                // - SYNC se déclenche deux fois (une par mutation),
                // - PRE puis POST ne se déclenchent qu'une fois (mutations regroupées) et lisent des états DOM différents.
                const doublerVite = () =>
                {
                    // Vider le tableau pour facilier la lecture du résutlat lors des tests.
                    logs.value = [];

                    // Double incrémentation de "compteur".
                    compteur.value++;
                    compteur.value++;
                }
                </script>

                <template>
                <section>
                    <!-- La ref "titre" est reliée via useTemplateRef('titre') dans le script -->
                    <h2 ref="titre">{{ compteur }}</h2>

                    <button @click="doublerVite">+2 vite</button>

                    <!-- On affiche le journal. v-html permet de garder <b> et <br> pour une lecture claire. -->
                    <p v-for="(l, i) in logs" :key="i" v-html="l"></p>
                </section>
                </template>
            

Résultat après le premier clic "+2 vite" :

Annuler et nettoyer un effet déclenché par un observateur

Lorsqu'un watch() exécute un effet persistant entre deux tours d'exécution (requête en vol, timer, écouteur, abonnement, instance externe), il faut annuler l'effet précédent avant le suivant. Sans ce nettoyage, on crée des courses (réponses obsolètes qui arrivent après les nouvelles), des fuites (timers ou écouteurs jamais libérés) et des états incohérents. Ce besoin concerne souvent des effets asynchrones, mais aussi des effets synchrones qui survivent au callback, comme un addEventListener.

Pour gérer ces cas, Vue fournit la fonction onInvalidate() qui, dans watch(), est passée comme troisième paramètre du callback : watch(source, (nouveau, ancien, onInvalidate) => { ... }).

Appeler onInvalidate(handler) enregistre une fonction de nettoyage appelée avant la prochaine exécution du callback et à la destruction du watcher.

Cas d'usage typiques

Dans la pratique, il s'agit par exemple d'annuler une requête fetch() en cours, de nettoyer un setTimeout() ou un setInterval(), de retirer un addEventListener, de résilier un abonnement ou un stream, ou encore de fermer une connexion et de libérer une instance externe.

Exemple d'activation/désactivation d'un écouteur clavier avec onInvalidate()

Dans cet exemple très simple, l'utilisateur coche une case pour activer un raccourci clavier. Tant que l'option est active, la flèche haut incrémente un compteur. Si l'utilisateur décoche, l'écoute clavier est automatiquement retirée. Pas de requête, pas de délai : juste un écouteur ajouté, puis nettoyé grâce à onInvalidate().


                <script setup>
                import { ref, watch } from 'vue';

                const ecouteActive = ref(false);
                const compteur = ref(0);

                // On observe "ecouteActive".
                // - Si elle devient true : on ajoute un écouteur clavier.
                // - "_ancien" le préfixe "_" indique qu'il ne sera pas utilisé (convention).
                // - "onInvalidate" enregistre le NETTOYAGE (removeEventListener) à exécuter
                //   avant la prochaine exécution du watcher et à l'arrêt du watcher/composant.
                watch(ecouteActive, (nouvelleValeurEcouteActive, _ancien, onInvalidate) =>
                {
                    // Si l'écoute n'est pas demandée, on ne branche rien.
                    if (!nouvelleValeurEcouteActive) return;

                    // Gestionnaire défini pour CETTE exécution du watcher.
                    const onKey = (e) =>
                    {
                        if (e.key === 'ArrowUp') compteur.value++;
                    };

                    // Effet persistant : écouter les frappes clavier sur "window".
                    window.addEventListener('keydown', onKey);

                    // Nettoyage garanti : retirer l'écouteur AVANT la prochaine exécution
                    // (peu importe la future valeur d'"ecouteActive") et à la destruction.
                    onInvalidate(() => window.removeEventListener('keydown', onKey));
                })
                </script>

                <template>
                    <label>
                        <input type="checkbox" v-model="ecouteActive">
                        Activer le raccourci clavier « Flèche haut »
                    </label>

                    <p>Compteur : {{ compteur }}</p>
                    <p>Astuce : cochez la case, puis appuyez sur « Flèche haut » pour incrémenter.</p>
                </template>
            

Ici, l'effet persistant est l'écouteur ajouté à window. À chaque changement de ecouteActive, Vue appelle d'abord la fonction enregistrée via onInvalidate() pour retirer l'écouteur courant, puis exécute à nouveau le watcher avec l'état le plus récent. Ainsi, aucun écouteur fantôme ne s'accumule.

Annuler une requête réseau avec fetch()

Souvent, on a besoin d'interrompre proprement une requête en cours pour éviter d'afficher des résultats obsolètes, de gaspiller du réseau ou du CPU et de déclencher des effets de bord inutiles.

Avant d'utiliser onInvalidate() pour annuler une requête HTTP fetch(), on a besoin de AbortController dans ce contexte afin d'assurer une annulation propre et cohérente.

Comprendre AbortController

AbortController permet d'annuler proprement des opérations asynchrones comme fetch(). On l'utilise quand une action utilisateur rend une requête précédente obsolète (saisie rapide, changement de filtre, navigation) pour économiser réseau/CPU et garantir que seule la dernière réponse est prise en compte.

Le contrôleur AbortController est un objet de l'API Web (Javacript vanilla) qui expose un signal d'annulation et une méthode abort(). On crée ce contrôleur, on transmet son signal à fetch() puis on appelle controller.abort() pour interrompre une requête en cours.

L'exemple suivant illustre l'utilisation du contrôleur AbortController dans une requête fetch() fictive. Cet exemple est présenté à des fins pédagogiques et n'est pas fonctionnel tel quel afin de simplifier la compréhension de ce nouveau concept. Une version fonctionnelle avec Vue.js est proposée juste après.


                // Créer un contrôleur pour pouvoir ANNULER la requête en cours si nécessaire.
                const controleur = new AbortController();

                // Déstructurer la propriété "signal" depuis le contrôleur.
                //    - Équivalent à : const signal = controleur.signal
                //    - Type         : AbortSignal (API Web standard).
                //    - Rôle         : indiquer à fetch() si/quant la requête doit être interrompue.
                const { signal } = controleur;

                // Lancer la requête en transmettant le "signal" d'annulation.
                // fetch() écoute ce signal et rejettera avec "AbortError" si "controleur.abort()" est appelé.
                const promesse = fetch('https://exemple.api/endpoint', { signal });

                try
                {
                    const r = await promesse;
                    // Traiter la réponse...
                }
                catch (e)
                {
                    // Si la requête a été annulée avec controleur.abort(), fetch() lève une erreur "AbortError".
                    if (e.name === 'AbortError') 
                    {
                        // Annulation normale (il n'est pas nécessaire de gérer l'erreur).
                        // Option : "signal.reason" pour lire la raison personnalisée via l'argument passé à controleur.abort().
                        return;
                    }
                    // Autre erreur (réseau/HTTP/parsing) : à gérer/loguer.
                    console.error(e);
                }

                // Plus tard, déclenché par un autre événement...
                function nettoyer() 
                {
                    // Interrompre la requête encore en cours (si elle n'est pas terminée).
                    // Le signal passe à l'état "aborted" et la promesse de fetch rejette 
                    // avec "AbortError".
                    controleur.abort('Interruption volontaire de la requête pour x raison...')
                }
            

Exemple d'annulation de fetch avec onInvalidate et AbordError

Dans l'exemple suivant, une requête réseau est lancée puis, si l'utilisateur enchaîne immédiatement avec une nouvelle requête, la requête en cours est annulée afin d'éviter d'afficher un résultat déjà dépassé.


                <script setup>
                import { ref, watch } from 'vue';

                const recherche = ref('');

                // Contiendra la réponse de la requête fetch 
                // sous form d'objet { products, total, skip, limit }.
                const resultat = ref(null);

                // Contiendra un message d'erreur lisible par l'utilisateur
                // (ex.: réseau indisponible, statut HTTP non OK, parsing JSON impossible).
                const erreur = ref(null);

                // Indique visuellement dans l'UI qu'une requête est en train 
                // d'être exécutée (ex.: affichage d'un spinner ou "Chargement...").
                const enCours = ref(false);

                // Callback asynchrone (il utilise fetch()), donc on le déclare avec "async".
                // "onInvalidate" permettra d'annuler la requête en cours au besoin.
                watch(recherche, async (nouvelleRecherche, _ancien, onInvalidate) =>
                {
                    // Si la saisie est vide, on nettoie l'affichage et on évite l'appel réseau.
                    if (!nouvelleRecherche)
                    {
                        resultat.value = null;
                        erreur.value = null;
                        enCours.value = false;
                        return;
                    }

                    // Préparer l'annulation de la requête précédente si nécessaire.
                    const controleur = new AbortController();
                    const signal = controleur.signal;

                    // onInvalidate(fonctionDeRappel) : enregistre le "nettoyage" de la requête courante.
                    //
                    // Vue appellera la fonction de rappel :
                    //   1. juste avant la PROCHAINE exécution du watcher,
                    //   2. et au moment où le watcher est stoppé/détruit.
                    //
                    // Le timing suit l'option "flush" (pre/post/sync).
                    onInvalidate(() => controleur.abort());

                    // Réinitialiser l'état avant de lancer la requête HTTP :
                    //   1. enCours : active état de chargement en cours.
                    //   2. erreur  : efface tout message d'erreur précédent.
                    enCours.value = true;
                    erreur.value = null;

                    try
                    {
                        // API publique de test (DummyJSON) : Renvoie une liste de produits (articles)
                        // avec titre, marque, catégorie, etc., plus des méta-infos (total, skip, limit).
                        //
                        // encodeURIComponent(recherche.value) : Encoder la recherche entrée par l'utilisateur 
                        // pour garantir une URL valide.
                        const url = `https://dummyjson.com/products/search?q=${encodeURIComponent(recherche.value)}`;

                        // Envoyer la requête et attendre la réponse.
                        // On transmet aussi "signal" pour pouvoir annuler cette requête si elle devient obsolète.
                        const reponse = await fetch(url, { signal });

                        // Si la réponse HTTP n'indique pas un succès (statut 200-299), on déclenche une erreur explicite.
                        if (!reponse.ok) throw new Error('Réponse serveur invalide');

                        // Extraire le corps de la réponse au format JSON.
                        const donneesDeLaReponse = await reponse.json()

                        // Mettre à jour la donnée réactive "resultat" avec l'objet reçu.
                        // (Déclenchera la mise à jour de l'UI.)
                        resultat.value = donneesDeLaReponse;
                    }
                    catch (e)
                    {
                        // Si l'erreur est due à un abort, on ignore (c'est un nettoyage attendu)
                        if (e.name !== 'AbortError') erreur.value = e.message;
                    }
                    finally
                    {
                        enCours.value = false;
                    }
                }, { flush: 'post' }); // 'post' si on lit le DOM après mise à jour
                </script>

                <template>
                    <label for="search">Recherche :</label>
                    <input id="search" v-model.trim="recherche" placeholder="Ex. : phone, laptop, perfume...">

                    <p v-if="enCours">Chargement…</p>
                    <p v-if="erreur">Erreur : {{ erreur }}</p>

                    <div v-if="resultat">
                        <p>Résultats : {{ resultat.total }} élément(s).</p>
                        <ul>
                            <li v-for="p in resultat.products" :key="p.id">
                                {{ p.title }} — {{ p.brand }} ({{ p.category }})
                            </li>
                        </ul>
                    </div>
                </template>
            

Ici, chaque frappe déclenche un nouveau watch(). La fonction passée à onInvalidate() annule la requête précédente via AbortController, ce qui supprime les résultats périmés. Les états enCours, erreur et resultat pilotent l'interface pour rester réactive et lisible.

Ce pattern garantit une expérience fluide, une seule requête active à la fois, aucune fuite de ressources, et toujours la réponse la plus récente à l'écran.

Optimiser l'utilisation de watch()

L'objectif est de limiter le travail de Vue et d'éviter des déclenchements inutiles. On réduit la surface observée, on évite les observations profondes quand elles ne sont pas nécessaires, et on choisit des sources simples et ciblées.

Privilégier des sources ciblées plutôt qu'un deep global

Observer un objet entier (reactive() ou ref()/computed() avec deep:true) force Vue à suivre toutes les propriétés internes. Cela multiplie les dépendances et les recalculs. Il est préférable de n'observer que la partie utile de la donnée.

Suivre une valeur à l'aide d'un lecteur réactif (fonction fléchée)

Lorsqu'on souhaite observer une propriété précise d'un objet réactif, il faut passer à watch() une fonction fléchée (ex.: () => state.user.nom) plutôt que la propriété elle-même (state.user.nom).

Cette distinction est essentielle car watch() a besoin d'une fonction de lecture pour réévaluer la source à chaque changement. Si l'on fournit directement la propriété, Vue ne reçoit qu'une valeur figée et ne sait pas la recalculer ensuite.

L'exemple suivant montre comment utiliser une fonction fléchée comme "lecteur réactif" dans watch(). On y voit pourquoi il faut passer une fonction (() => ...) et non une valeur directe, afin que Vue relise la donnée à chaque modification et déclenche le watcher au bon moment.


                <script setup>
                import { reactive, watch } from 'vue';

                // Objet réactif à plusieurs niveaux.
                // Créé avec reactive(), il rend chaque propriété interne réactive.
                const state = reactive({
                    user: { nom: 'Alice', age: 25 },
                    items: []
                });


                // ---------------------------------------------------
                // Watch global de l'objet "state" (non recommandé)
                // ---------------------------------------------------

                // Exemple "gourmand" : observer tout l'objet réactif.
                // Comme "state" est créé avec reactive(), le watcher sera déclenché
                // à la moindre mutation, même si le changement ne nous concerne pas.
                watch(state, () => 
                {
                    console.log('--------------------------');
                    console.log('watch(state) : Un changement quelconque a eu lieu');
                });


                // ----------------------------------------------
                // Watch ciblé sur la propriété "state.user.nom"
                // ----------------------------------------------

                // On demande à Vue d'observer UNIQUEMENT "user.nom".
                // La fonction fléchée (() => state.user.nom) sert de lecteur réactif :
                // Vue l'exécute à chaque tick pour vérifier si la valeur a changé.
                watch(() => state.user.nom, () => 
                {
                    console.log('watch(() => state.user.nom) : Le nom a changé');
                });


                // -----------------------------------------------------
                // Watch ciblé sur la longueur de "state.items"
                // -----------------------------------------------------

                // On n'observe pas tout le tableau, mais sa longueur.
                // Le watcher se déclenche seulement quand on ajoute/retire un élément.
                watch(() => state.items.length, () => 
                {
                    console.log('watch(() => state.items.length) : La taille de la liste a changé');
                });


                // ------------------------------------------------------------------------------
                // Mauvais usage : passer directement la valeur primitive state.user.age
                // ------------------------------------------------------------------------------

                // Cette syntaxe ne déclenche rien : watch reçoit un nombre lu une fois (25),
                // pas une fonction qu'il pourrait réévaluer plus tard.
                // Il faudrait écrire : watch(() => state.user.age, ...)
                watch(state.user.age, () => 
                {
                    console.log('watch(state.user.age) : L\'âge a changé');
                });


                // ----------------------------------------------------
                // Fonction utilitaire pour déclencher un changement
                // ----------------------------------------------------

                // Ajoute un nombre aléatoire entre 0 et 10 au tableau "items".
                const ajouterItemAleatoire = () => 
                {
                    const nombreAleatoire = Math.floor(Math.random() * 11);
                    state.items.push(nombreAleatoire);
                };
                </script>

                <template>
                <section>
                    <!-- Modification de user.nom -->
                    <input v-model.trim="state.user.nom" placeholder="Modifier le nom">
                    <p>Nom actuel : {{ state.user.nom }}</p>

                    <!-- Modification de user.age -->
                    <input v-model.number="state.user.age" placeholder="Modifier l'âge">
                    <p>Âge actuel : {{ state.user.age }}</p>

                    <!-- Ajout d'un élément aléatoire -->
                    <button @click="ajouterItemAleatoire">Ajouter un item aléatoire</button>
                    <p>Items : {{ state.items }}</p>
                </section>
                </template>
            

La fonction fléchée comme lecteur réactif n'est pas la seule façon d'optimiser un watch(). D'autres fonctionnalités de l'API réactive permettent de cibler finement la donnée observée, de réduire la surface suivie et d'alléger le travail de Vue.

toRef() crée une référence directe vers une propriété précise d'un objet réactif. Cela permet de l'observer plus facilement avec watch() sans devoir utiliser une fonction fléchée à chaque fois. Cette approche est pratique lorsque la même propriété doit être suivie ou modifiée à plusieurs endroits du code. Une présentation complète de cette fonctionnalité est disponible dans la section consacrée.

shallowRef() suit uniquement la référence. Les mutations internes de l'objet stocké ne sont pas observées, mais tout remplacement de la valeur déclenche le watch(). Particulièrement adapté aux jeux de données remplacés en bloc ou aux instances de bibliothèques externes. Une présentation complète de cette fonctionnalité est disponible dans la section consacrée.

shallowReactive() rend réactif uniquement le premier niveau d'un objet. Les propriétés imbriquées ne sont pas suivies, ce qui réduit le coût du suivi réactif sur les structures volumineuses ou complexes. Cette approche est utile pour des conteneurs dont la structure change souvent, ou lorsque les sous-objets sont gérés par un autre système. Une présentation complète de cette fonctionnalité est disponible dans la section consacrée.

markRaw() permet d'exclure volontairement une partie d'un objet du système réactif. Les données marquées ne sont plus suivies, et leurs mutations internes ne déclenchent aucun rendu. Ce comportement permet d'élaguer la réactivité de manière sélective, par exemple pour des structures volumineuses, des objets tiers ou des caches non destinés à l'affichage. On peut toutefois réassigner la propriété complète par une nouvelle référence afin de forcer une mise à jour si nécessaire. Une présentation complète de cette fonctionnalité est disponible dans la section consacrée.

Ces fonctionnalités peuvent se combiner. On peut par exemple stocker un tableau lourd dans une shallowRef() puis observer sa longueur via un lecteur réactif, ou bien utiliser toRef() sur une propriété clé d'un objet géré en shallowReactive(). L'objectif reste de réduire les observations inutiles et de cibler exactement ce qui doit déclencher un effet.

Suspendre un watch() quand il n'est plus utile

Suspendre un observateur consiste à arrêter volontairement l'écoute d'une source réactive. L'intérêt est d'éviter un coût permanent quand la surveillance n'apporte plus rien, par exemple après une action ponctuelle, la fermeture d'un volet, un envoi de formulaire ou une étape de workflow terminée.

On le fait pour économiser des ressources et éviter des effets secondaires non désirés. Un observateur inutile reste déclenché à chaque changement de données, ce qui peut provoquer des traitements superflus. Les composants détruits nettoient automatiquement leurs observateurs, mais il est souvent utile de couper plus tôt la surveillance au sein d'un composant encore actif.

Comment procéder watch() retourne une fonction d'arrêt. Il suffit de stocker ce retour, puis d'appeler cette fonction quand on veut désactiver l'observateur. On peut aussi recréer un observateur plus tard si nécessaire en rappelant watch(). Il est possible de l'arrêter depuis son propre rappel selon une condition métier, par exemple après la première occurrence qui nous intéresse.

L'exemple suivant montre un observateur que l'on peut démarrer et arrêter manuellement. Tant que l'observateur est actif, chaque clic sur le bouton Incrémenter affiche un message et incrémente un compteur de logs. Après l'arrêt, les changements de valeur ne produisent plus aucun effet.


            <script setup>
            import { ref, watch } from 'vue';

            const compteur = ref(0);
            const logs = ref(0);

            // Contiendra la fonction d'arrêt renvoyée par watch().
            // null signifie qu'aucun watcher n'est actif.
            let stopHandle = null;

            // Démarre l'observateur si absent.
            const demarrerWatch = () => 
            {
                // Ne pas aller plus loin si l'observateur est déjà actif.
                if (stopHandle) return;

                // Stocker la fonction d'arrêt du watch() dans la variable stopHandle.
                // Observer "compteur" et à chaque fois que sa valeur est réassignée,
                // la valeur de "log" est incrémentée.
                stopHandle = watch(compteur, () => 
                {
                    logs.value++;
                });
            };

            // Arrête l'observateur s'il est actif.
            const arreterWatch = () => 
            {
                // Ne pas aller plus loin si l'observateur est inactif.
                if (!stopHandle) return;

                // Désactive l'observateur.
                stopHandle();

                // Remplacer la fonction de désactivation par null.
                stopHandle = null;
            };

            // Déclenche un changement de valeur.
            const incrementer = () => 
            {
                compteur.value++;
            };
            </script>

            <template>
            <section>
                <p>Compteur : {{ compteur }}</p>
                <p>Logs enregistrés (si watch actif) : {{ logs }}</p>

                <button @click="incrementer">Incrémenter le compteur</button>
                <button @click="demarrerWatch">Démarrer le watcher</button>
                <button @click="arreterWatch">Arrêter le watcher</button>
            </section>
            </template>
            

Une fois la surveillance démarrée avec demarrerWatch(), chaque modification de compteur exécute le rappel et incrémente log. Dès que l'observateur est arrêté, le suivi s'interrompt immédiatement et les logs cessent d'être incrémentés tant qu'il n'est pas relancé.

Ce mécanisme montre qu'il est possible de suspendre une observation à tout moment pour économiser des ressources ou éviter des traitements inutiles. À noter que watch() est automatiquement nettoyé lorsque le composant est détruit, mais un arrêt manuel reste pertinent lorsqu'une observation n'a plus d'intérêt avant cette étape.

Structure et fonctionnement de watchEffect()

watchEffect() est une alternative à watch(). L'effet démarre dès sa création comme le ferait un watch() réglé sur immediate true. Il repère automatiquement les données réactives lues dans sa fonction de rappel, à la manière de computed, et relance cette fonction dès qu'une de ces données change. Il ne fournit pas les valeurs ancienne et nouvelle au rappel, mais celui-ci reçoit onInvalidate pour nettoyer un effet persistant.

La fonction watchEffect() définit un paramètre obligatoire et accepte un deuxième paramètre optionnel :

  1. La fonction de rappel déclenchée à chaque fois que l'une des données réactives étant présente dans son bloc d'instructions change.
  2. Les options de configuration (facultatif) : fush, onTrack et onTrigger.

Exemple pour sauver un brouillon dans localStorage

Dans l'exemple suivant, l'utilisateur saisit un titre et un contenu et à chaque modification, un brouillon est automatiquement enregistré afin d'être retrouvé au prochain chargement de la page.


                <script setup>
                import { ref, watchEffect } from 'vue';

                // Champs du formulaire.
                const titre = ref(localStorage.getItem('draft:titre') || '');
                const contenu = ref(localStorage.getItem('draft:contenu') || '');

                // watchEffect lit "titre.value" et "contenu.value" → ils deviennent des dépendances.
                // À chaque changement, l'effet est relancé et on persiste le brouillon.
                // Remarque : pas d'ancien/nouveau ici ; on réagit à l'état courant.
                watchEffect(() => 
                {
                    localStorage.setItem('draft:titre', titre.value);
                    localStorage.setItem('draft:contenu', contenu.value);
                });
                </script>

                <template>
                <div>
                    <label for="titre">Titre</label>
                    <input id="titre" v-model.trim="titre" placeholder="Mon article" />

                    <label for="contenu">Contenu</label>
                    <textarea id="contenu" v-model="contenu" rows="5"></textarea>
                    <p>Brouillon auto-sauvegardé 💾</p>
                </div>
                </template>
            

L'effet accède à titre.value et à contenu.value, ce qui en fait ses dépendances. watchEffect() se relance automatiquement dès que l'une d'elles change, sans que vous ayez à déclarer la moindre source. Lorsque vous devez effectuer un nettoyage, par exemple pour arrêter un timer ou retirer un écouteur, la fonction fournie à l'effet reçoit un onInvalidate qui fonctionne exactement comme celui de watch().

Variantes de watchEffect()

Deux variantes officielles existent watchPostEffect et watchSyncEffect.

Exercices

Exo-watch-01 : Sauvegarde centralisée de la todoliste