Les propriétés calculées

Introduction

Dans Vue, chaque fois qu'une donnée réactive est modifiée dans le bloc <script>, le composant est rendu à nouveau afin de mettre à jour automatiquement le contenu affiché dans le bloc <template>. C'est ce qu'on appelle la réactivité, c'est-à-dire que l'interface reste toujours synchronisée avec les données.

Lors de chaque nouveau rendu, Vue réévalue toutes les expressions présentes dans le <template>. Ainsi, toutes les fonctions appelées dans le template sont exécutées à chaque rendu, même si les données réactives modifiées ne sont pas directement utilisées par celles-ci.

Ce comportement garantit la cohérence de l'affichage, mais il peut aussi entraîner des problèmes de performance lorsque certaines fonctions sont coûteuses ou appelées à plusieurs reprises dans le template.

Pour optimiser le rendu, dès qu'une valeur affichée dans le <template> provient d'une fonction qui calcule une donnée à partir d'états réactifs, il faut utiliser une propriété calculée. Son résultat est mis en cache et réévalué uniquement lorsque les données réactives dont elle dépend changent.

En revanche, lorsqu'une expression simple est directement écrite dans les moustaches (par exemple {{ total + 1 }} ou {{ prenom + ' ' + nom }}), il n'est pas nécessaire d'utiliser une propriété calculée, tant que l'expression reste triviale et n'est pas répétée à plusieurs endroits. Vue sait gérer ces expressions sans recalcul coûteux.
Cependant, par cohérence et pour faciliter l'évolution du code, de nombreux développeurs choisissent malgré tout d'utiliser computed() dès qu'une valeur dérive de données réactives, même lorsque le calcul est simple. Cela permet de garder une structure de code claire et homogène, surtout dans les projets appelés à évoluer.

Fonctionnement

Lorsqu'une fonction est appelée directement dans le <template>, Vue l'exécute à chaque nouveau rendu du composant, même si les données réactives modifiées ne sont pas utilisées par cette fonction. Ce comportement garantit la cohérence de l'affichage, mais il peut aussi provoquer des recalculs inutiles.

Une propriété calculée permet d'éviter ces recalculs en dérivant une valeur à partir d'autres données réactives (ref(), reactive(), etc.). Vue met le résultat en cache et ne le réévalue que lorsque l'une des dépendances réactives change.

Pour créer une propriété calculée, on place le bloc d'instructions de la fonction dans computed(), sous la forme d'une fonction fléchée. Vue évalue cette fonction une première fois, puis uniquement lorsque ses dépendances réactives changent.

La valeur retournée par computed() devient elle-même une donnée réactive. On peut donc l'afficher directement dans le template, comme n'importe quelle variable issue de ref(). Le résultat est automatiquement mis en cache et mis à jour uniquement lorsque la donnée d'origine change.

Exemple

Sans propriété calculée

Sans propriété calculée, la fonction obtenirMessageEnMajuscules() est exécutée à chaque fois que la donnée réactive compteur est mise à jour (en l'occurrence, lorsque l'utilisateur clique sur le bouton d'incrémentation), alors qu'elle ne dépend pas de cette donnée.


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

                const message = ref('Message de test');
                const compteur = ref(0);

                const obtenirMessageEnMajuscules = () => 
                {
                    console.log('obtenirMessageEnMajuscules exécutée');
                    return message.value.toUpperCase();
                };

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

                <template>
                    <p>Message : {{ obtenirMessageEnMajuscules() }}</p>
                    <p>Compteur : {{ compteur }}</p>
                    <button type="button" @click="incrementer">Incrémenter</button>
                </template>
            

Ouvrez la console du navigateur et cliquez plusieurs fois sur le bouton d'incrémentation. Vous verrez que la fonction obtenirMessageEnMajuscules() s'exécute à chaque rendu, même si message n'a pas changé.

Cet exemple est volontairement simple. Ici, l'impact est négligeable. Mais dans des composants plus complexes (listes triées, filtres, calculs de moyenne, etc.), réévaluer une fonction à chaque rendu peut ralentir l'application ou provoquer des effets de bord. Il est donc préférable de déplacer la logique dérivée dans le <script> à l'aide d'une propriété calculée.

Avec propriété calculée

On fait passer le bloc d'instructions de la fonction obtenirMessageEnMajuscules() en argument à computed() sous la forme d'une fonction fléchée. La fonction associée est évaluée une première fois, puis seulement lorsque ses dépendances changent.

Vue enregistre automatiquement le résultat d'une propriété calculée et le réutilise lors des prochains rendus. Ce comportement s'appelle la mise en cache. La fonction n'est réévaluée que si une donnée réactive dont elle dépend change, ce qui évite d'exécuter inutilement le calcul à chaque rendu.

Exemple avec mise en cache

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

                const message = ref('Message de test');
                const compteur = ref(0);

                const messageEnMajuscules = computed(() => 
                {
                    console.log('computed évalué');
                    return message.value.toUpperCase();
                });

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

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

Dans cette version, la console affiche "computed évalué" uniquement lorsque message est modifié. Les clics sur le bouton d'incrémentation n'entraînent plus de recalcul de messageEnMajuscules tant que message ne change pas. La mise en cache évite ainsi les exécutions inutiles.

Rappel

Tout comme les autres expressions évaluées dans le <template>, une propriété calculée doit rester pure, c'est-à-dire qu'elle se limite à lire des valeurs et retourne un résultat calculé à partir de ces valeurs, sans modifier quoi que ce soit en dehors de son propre bloc d'instructions. Elle doit donc toujours produire le même résultat pour les mêmes entrées, quel que soit le moment où elle est exécutée.

Si une propriété calculée modifie une donnée réactive, elle provoque une mise à jour du composant, ce qui déclenche sa propre réévaluation, puis un nouveau rendu, et ainsi de suite. On obtient alors une boucle de rendu infinie.

Cas d'usage typiques

Éviter les recalculs inutiles

Même si une opération est simple, comme le calcul d'une moyenne, il est préférable de la placer dans une propriété calculée lorsque le résultat dépend de données réactives susceptibles de changer. Cela permet d'éviter de répéter le calcul à chaque rendu du composant.


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

                // Données réactives : tableau de notes
                const notes = ref([12, 15, 18, 10, 14]);

                // Propriété calculée dérivée : moyenne des notes
                // computed() rend ce calcul réactif et met le résultat en cache.
                // Le calcul est relancé uniquement si le contenu du tableau 'notes' change.
                const moyenne = computed(() => 
                {
                    const total = notes.value.reduce((a, b) => a + b, 0);
                    const moyenne = total / notes.value.length;

                    // La méthode "toFixed(n)" arrondit la moyenne à n décimales et retourne une chaîne de caractères. 
                    // "Number()" permet de convertir le résultat en nombre.
                    return Number(moyenne.toFixed(2));
                });

                // Ajoute une note aléatoire (valeur entre 8 et 20).
                const ajouterNoteAleatoire = () => 
                {
                    const nouvelleNote = Math.floor(Math.random() * 13) + 8;
                    notes.value.push(nouvelleNote);
                };
                </script>

                <template>
                    <h4>Notes : {{ notes.join(', ') }}</h4>
                    <p>Moyenne : {{ moyenne }}</p>
                    <button @click="ajouterNoteAleatoire">Ajouter une note aléatoire</button>
                </template>
            

Ici, la propriété calculée moyenne dépend directement de la donnée réactive notes. Vue met automatiquement le résultat en cache et ne relance le calcul que lorsque le tableau est modifié, par exemple lorsqu'on ajoute une nouvelle note. Ce mécanisme évite les recalculs inutiles à chaque rendu tout en gardant l'interface parfaitement synchronisée avec les données.

Même si le calcul est simple, l'utiliser dans une computed() reste une bonne pratique dès qu'il dépend de données réactives, afin d'améliorer la clarté et les performances du composant.

Centraliser une règle d'affichage commune

Une propriété calculée peut aussi servir à centraliser une logique utilisée à plusieurs endroits du template. Cela permet d'assurer une cohérence d'affichage et d'éviter les divergences lorsque la même règle doit être appliquée plusieurs fois. En regroupant le calcul dans un seul endroit, le code devient plus clair, plus fiable et plus simple à maintenir.


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

                // Donnée réactive représentant le stock d'un produit.
                const stock = ref(3);

                // Propriété calculée : détermine le message à afficher selon le stock disponible.
                const statut = computed(() => 
                {
                    if (stock.value <= 0) return 'Rupture';
                    if (stock.value <= 2) return 'Bientôt épuisé';
                    return 'En stock';
                });

                // Fonction qui simule la vente d'un article.
                const vendreUn = () =>
                {
                    if (stock.value > 0) stock.value -= 1;
                };
                </script>

                <template>
                    <button @click="vendreUn">Vendre 1</button>

                    <p>Stock : {{ stock }}</p>
                    <p>Badge : {{ statut }}</p>
                    <p>Info produit : Statut {{ statut }} ( reste {{ stock }} )</p>
                </template>
            

Dans cet exemple, la logique conditionnelle qui définit le statut du produit est centralisée dans une seule propriété calculée. Le même résultat est réutilisé dans plusieurs zones du template (badge, description, message informatif), sans risque d'incohérence. Si les règles de seuil changent plus tard (par exemple : "bientôt épuisé" à partir de 5), il suffira de modifier une seule ligne dans le calcul, et tous les affichages resteront automatiquement synchronisés.

Isoler des transformations sans modifier la source

On dérive souvent de nouvelles données à partir d'une source existante, par exemple en filtrant ou en triant une liste. Placer cette logique dans une propriété calculée permet de préserver la donnée originale et d'éviter les effets de bord, c'est-à-dire les modifications involontaires d'un état partagé. Cette approche rend le code plus lisible, plus prévisible et plus sûr.

Dans l'exemple ci-dessous, on prépare une liste de produits affichée selon deux critères réactifs, le texte de recherche et une option de tri alphabétique. Deux propriétés calculées sont utilisées pour isoler ces étapes, la première filtre la liste et la seconde la trie.


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

                const recherche = ref('');
                const trierParOrdreAlpha = ref(false);

                const produits = ref([
                    { id: 1, nom: 'Casque Sony', prix: 79 },
                    { id: 2, nom: 'Clavier Corsair', prix: 149 },
                    { id: 3, nom: 'Casque Bose', prix: 199 },
                    { id: 4, nom: 'Souris Logitech', prix: 59 }
                ]);

                // Première propriété calculée : filtre les produits selon la recherche.
                const produitsFiltres = computed(() => 
                {
                    // On nettoie la recherche :
                    // - trim() retire les espaces avant et après ;
                    // - toLowerCase() : convertit la recherche en minuscules pour normaliser la comparaison à venir.
                    const rechercheNettoyee = recherche.value.trim().toLowerCase();

                    // Si le champ est vide, on retourne directement la liste complète.
                    if (!rechercheNettoyee) 
                    {
                        return produits.value;
                    }

                    // On retourne un nouveau tableau contenant uniquement les produits dont
                    // le nom inclut le texte saisi (comparé en minuscules).
                    // filter() ne modifie pas le tableau d'origine.
                    return produits.value.filter(p => 
                        p.nom.toLowerCase().includes(rechercheNettoyee)
                    );
                });

                // Deuxième propriété calculée : trie la liste filtrée si nécessaire.
                const produitsAffiches = computed(() => 
                {
                    // On part de la version filtrée.
                    const base = produitsFiltres.value;

                    if (trierParOrdreAlpha.value) 
                    {
                        // toSorted() crée un tableau trié sans modifier l'original.
                        // localeCompare() compare deux chaînes selon l'ordre alphabétique
                        // réel de la langue, en tenant compte notamment des accents.
                        return base.toSorted((a, b) => a.nom.localeCompare(b.nom));
                    }

                    // Sinon, on garde l'ordre d'origine.
                    return base;
                });
                </script>

                <template>
                    <div>
                        <!-- Liaison bidirectionnelle (v-model) avec le champ de recherche -->
                        <input 
                            type="text" 
                            v-model="recherche" 
                            placeholder="Rechercher un produit…" 
                        />

                        <!-- Case à cocher pour activer le tri alphabétique -->
                        <label for="trierParOrdreAlpha">
                            Trier A->Z
                        </label>
                        <input 
                            id="trierParOrdreAlpha" 
                            type="checkbox" 
                            v-model="trierParOrdreAlpha" 
                        />
                    </div>

                    <!-- Liste finale, filtrée et triée -->
                    <ul>
                        <li v-for="p in produitsAffiches" :key="p.id">
                            {{ p.nom }} — {{ p.prix }} €
                        </li>
                    </ul>
                </template>
            

Cet exemple illustre l'intérêt de créer des données dérivées intermédiaires à l'aide de propriétés calculées. La liste d'origine produits reste intacte, tandis que produitsFiltres et produitsAffiches représentent des versions transformées, sans jamais altérer la source.

La méthode toSorted() est utilisée à la place de sort() car elle crée une copie triée du tableau au lieu de le modifier en place.

Cette comparaison simple (a.nom > b.nom ? 1 : -1) ne tient pas compte des accents. Pour trier correctement des chaînes comme "ecran" et "écran", utilisez plutôt localeCompare(). Cette méthode gère également la casse, ce qui rend inutile l'appel à toLowerCase() pour normaliser la comparaison. Vous pouvez consulter sa documentation sur le site de MDN pour en savoir plus.

Cette approche rend le code plus prévisible, plus maintenable et surtout plus sûr. Chaque transformation est clairement isolée, et la donnée de base reste inchangée.

Propriétés calculées inscriptibles

Jusqu'à présent, les propriétés calculées que nous avons vues servaient uniquement à lire des données pour en déduire une valeur à afficher. Mais il est aussi possible de créer une propriété calculée modifiable : une donnée dérivée qui peut être lue et mise à jour.

Pour cela, on utilise une version avancée de computed() qui reçoit un objet contenant deux fonctions : get (pour la lecture) et set (pour l'écriture). Le getter renvoie la valeur calculée à afficher, tandis que le setter est exécuté lorsque cette valeur est modifiée, par exemple lorsqu'on lui assigne une nouvelle donnée.

Ce mécanisme permet de créer des liaisons bidirectionnelles entre différentes variables réactives. Autrement dit, lorsque la valeur dérivée change, la donnée d'origine est automatiquement mise à jour, et inversement. Cela permet de garder les deux toujours synchronisées, sans devoir écrire du code de mise à jour manuelle.

Dans la pratique, cette approche est surtout utilisée avec v-model. Vue appelle automatiquement le getter pour afficher la valeur dans le champ, et le setter lorsqu'un utilisateur modifie la saisie. C'est donc une manière simple et centralisée de contrôler ou adapter les données saisies par l'utilisateur avant de les enregistrer (par exemple convertir du texte en nombre ou remplacer une virgule par un point dans un champ décimal).

Le setter est exécuté uniquement lors d'une action explicite, comme une saisie utilisateur ou une assignation via v-model. Il ne s'exécute jamais pendant le rendu, et Vue gère ce comportement de manière contrôlée. Son rôle est de mettre à jour les données directement liées à la propriété calculée, sans provoquer d'effet secondaire inattendu ailleurs dans le composant. Il s'agit d'un point d'extension prévu par le cycle réactif, et non d'un effet de bord au sens problématique du terme.

Exemple : Champ monétaire réactif (affichage en euros, stockage en centimes)

Ce mécanisme devient particulièrement utile dans les situations où le format de saisie visible dans l'interface n'est pas le même que le format de stockage utilisé en interne. C'est un cas très fréquent dans les applications web, notamment pour les données financières.

En pratique, les développeurs évitent de stocker les montants en euros avec des décimales. En effet, les nombres à virgule flottante ne sont pas représentés exactement en mémoire, ce qui peut provoquer de petites erreurs d'arrondi lors des calculs. Par exemple, une addition comme 0.1 + 0.2 ne donne pas 0.3, mais 0.30000000000000004, ce qui peut devenir problématique lorsqu'on additionne ou compare plusieurs montants.

Pour éviter ce type d'imprécision, les montants sont presque toujours stockés en centimes dans les bases de données et dans le code, sous forme d'entiers. Cela permet d'effectuer tous les calculs sans erreur d'arrondi et de simplifier les comparaisons de valeurs.

L'affichage à l'écran, en revanche, doit rester en euros pour être compréhensible par l'utilisateur. On doit donc effectuer une conversion entre le format interne (centimes) et le format affiché (euros avec deux décimales). C'est précisément le type de situation où une propriété calculée avec get et set est idéale.

Le getter est utilisé pour convertir la valeur interne en euros avant de l'afficher, tandis que le setter s'exécute lorsqu'un utilisateur modifie le champ, afin de reconvertir la valeur saisie en centimes avant de la stocker. Cette logique reste ainsi centralisée, claire et automatique, sans devoir écrire du code de conversion ailleurs dans le composant.

Dans l'exemple suivant, nous allons voir comment ce système permet de lier un champ de saisie en euros à une variable réactive qui enregistre le prix en centimes, tout en gardant les deux valeurs parfaitement synchronisées.


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

                    // Stockage interne en centimes (entier).
                    const prixCts = ref(1299);

                    const prixAffiche = computed({
                        get() 
                        {
                            // Getter : conversion du montant (centimes en euros, arrondi à 2 décimales).

                            return (prixCts.value / 100).toFixed(2).replace('.', ',');
                        },
                        set(value) 
                        {
                            // Setter : conversion du montant (chaîne en nombre et euros en centimes).

                            // Remplacer la virgule (,) par un point (.) 
                            // puis convertir la valeur obtenue en nombre à l'aide de Number().
                            // Notez qu'il n'est pas nécessaire d'utiliser "trim" lors de ce type de conversion.
                            const n = Number(value.replace(',', '.'));

                            // Éviter d'actualiser "prixCts.value" si le résultat (n) n'est pas un nombre.
                            // Number.isFinite(n) retourne "true" si "n" est un nombre réel 
                            // et "false" dans tous les autres cas.
                            if (!Number.isFinite(n)) 
                            {
                                return;
                            }

                            // Met à jour "prixCts.value" après conversion du montant en centimes.

                            // En JavaScript, certaines opérations sur les nombres décimaux peuvent produire
                            // de légères erreurs d'arrondi à cause de la représentation binaire des nombres.
                            // Par exemple : 1.005 * 100 donne 100.49999999999999, qui serait arrondi à 100 au lieu de 101.
                            //
                            // L'ajout de "Number.EPSILON" (la plus petite différence représentable entre deux nombres)
                            // compense ces micro-erreurs en décalant légèrement la valeur avant l'arrondi,
                            // ce qui garantit un résultat correct même dans les cas limites.
                            prixCts.value = Math.round((n + Number.EPSILON) * 100);
                        }
                    });
                    </script>

                    <template>
                        <label>Prix (EUR)</label>

                        <!-- 
                            Le modificateur "lazy" est important : 
                            il indique à Vue de ne mettre à jour la donnée liée ("prixAffiche")
                            qu'au moment où l'utilisateur a terminé sa saisie 
                            (par exemple quand il quitte le champ ou appuie sur Entrée),
                            et non à chaque frappe. 
                            Cela évite que la valeur soit reformatée à chaque frappe,
                            ce qui pourrait déplacer le curseur ou perturber la saisie de l'utilisateur.

                            L'attribut HTML "inputmode" indique au navigateur quel type 
                            de clavier virtuel afficher (ex. décimal, numérique, e-mail, etc.).
                            Il est surtout utile sur les appareils à écran tactile.
                        -->
                        <input v-model.lazy="prixAffiche" inputmode="decimal" />

                        <p>Stockage interne : {{ prixCts }} centimes</p>
                        <p>Version pour l'affichage : {{ prixAffiche }} €</p>
                    </template>
                

Dans cet exemple, on sépare clairement le format d'affichage en euros, utilisé par l'utilisateur, du format de stockage en centimes, utilisé par le code et la base de données. La propriété calculée agit comme une passerelle réactive entre ces deux formats. Le getter convertit les centimes en euros à chaque lecture, tandis que le setter reconvertit automatiquement la saisie de l'utilisateur en centimes à chaque modification. Ce mécanisme garantit une synchronisation fluide entre l'interface et les données internes, sans devoir écrire du code supplémentaire dans le template ou dans les événements.

On pourrait penser qu'il suffit d'utiliser le modificateur v-model.number, mais il ne s'adapte pas à ce cas précis. Ce modificateur convertit la saisie en nombre dès que l'utilisateur tape une valeur, mais il ignore la virgule utilisée comme séparateur décimal dans la plupart des pays francophones. Par conséquent, une saisie comme 12,99 peut être interprétée comme 12, ce qui ferait perdre la partie décimale. Le setter personnalisé évite ce problème en remplaçant la virgule par un point avant la conversion, ce qui permet une saisie naturelle tout en conservant une valeur exacte et exploitable en interne.

Cette approche présente aussi un avantage en matière de maintenance. Toute la logique de conversion est regroupée dans une seule propriété, ce qui rend le code plus clair et réduit les risques d'erreur. Si le format d'affichage ou la méthode de conversion devait changer, il suffirait d'ajuster le getter et le setter sans toucher au reste du composant. C'est une manière à la fois propre, précise et durable de gérer ce type de transformation.

Exercices

Exo-computed-01 : Compteurs dérivés

Exo-computed-02 : États dérivés pour l'interface

Exo-computed-03 : Liste triée via propriété calculée