Retour au cours

frontend / vuejs

Props & emits

Leçon 51 exercice

Explication

Ce que vous allez apprendre

  • Déclarer des props typées et validées avec defineProps
  • Émettre des événements vers le parent avec defineEmits, avec validation optionnelle de la charge utile
  • Comprendre pourquoi le flux de données entre composants est strictement unidirectionnel
  • Créer un v-model personnalisé sur un composant custom
  • Lier plusieurs valeurs à la fois avec des arguments nommés de v-model

Dans quel contexte ?

Une grille de produits affiche des cartes réutilisables (CarteProduit), chacune devant recevoir les données du produit depuis son parent, et devant signaler au parent quand l'utilisateur clique sur "ajouter au panier". Ce va-et-vient entre parent et enfant est l'un des mécanismes les plus fondamentaux de Vue : sans lui, il serait impossible de découper une interface en composants réutilisables et indépendants.

D'abord, un flux de données à sens unique

Vue impose une règle stricte : les données descendent du parent vers l'enfant via les props, jamais l'inverse directement. Si l'enfant a besoin de signaler quelque chose au parent (un clic, une saisie), il émet un événement que le parent choisit ou non d'écouter. Ce flux unidirectionnel rend le comportement d'une application prévisible : on sait toujours d'où vient un changement de données.

defineProps({ produit: { type: Object, required: true, validator(valeur) { ... } } }) déclare non seulement le type attendu, mais peut aussi valider la forme exacte de la donnée reçue, avec un message d'avertissement clair en développement si elle ne correspond pas.

Prérequis

Cette leçon suppose une bonne compréhension de la Composition API et de <script setup> (leçon précédente).

Ensuite, comment l'enfant signale un événement au parent

defineEmits déclare la liste des événements qu'un composant peut émettre, avec une validation optionnelle de leur charge utile — exactement le même principe que la validation des props, mais appliqué à ce qui remonte plutôt qu'à ce qui descend. emit('ajouterPanier', { id, quantite }) déclenche l'événement, que le parent écoute avec @ajouter-panier="surAjoutPanier".

DirectionMécanismeExemple
Parent → EnfantProps:produit="p"
Enfant → ParentEmitemit('ajouterPanier', {...})

Piège fréquent

Muter directement une prop dans l'enfant (props.produit.prix = 0) fonctionne parfois silencieusement si c'est un objet (car la mutation touche l'objet partagé), mais casse le principe même du flux unidirectionnel et rend le bug très difficile à tracer plus tard. Une prop doit toujours rester en lecture seule côté enfant.

Il reste un cas très courant : la liaison bidirectionnelle simulée

v-model sur un composant HTML natif (comme <input>) est un raccourci bien connu. Sur un composant CUSTOM, v-model n'est en réalité qu'un sucre syntaxique qui combine une prop modelValue et un événement update:modelValue — le composant ChampMontant illustre exactement ce mécanisme sous-jacent.

Astuce

<ChampMontant v-model="prix" /> équivaut exactement à <ChampMontant :model-value="prix" @update:model-value="prix = $event" />. Comprendre cette équivalence permet de déboguer n'importe quel comportement v-model inattendu sur un composant personnalisé.

Maintenant, lier plusieurs valeurs à la fois

v-model peut cibler plusieurs propriétés nommées sur le même composant, comme v-model:min="prixMin" v-model:max="prixMax" sur un composant PlageDePrix. Chaque argument nommé fonctionne indépendamment, avec sa propre prop et son propre événement update:nom.

Maintenant que la communication entre composants est maîtrisée dans les deux sens, la prochaine leçon aborde les valeurs dérivées et les effets de bord réactifs : computed et watch.

Commandes & code

Props & emits

La communication descendante (parent → enfant) passe par les props, la remontée (enfant → parent) par les emits.

vue
<!-- components/CarteProduit.vue -->
<script setup>
// defineProps : déclaration typée et validée des props reçues
const props = defineProps({
  produit: {
    type: Object,
    required: true,
    validator(valeur) {
      return typeof valeur.prix === 'number' && valeur.prix >= 0
    },
  },
  enPromotion: { type: Boolean, default: false },
  quantiteMax: { type: Number, default: 10 },
})

// defineEmits : déclaration des événements que ce composant peut émettre,
// avec validation optionnelle de la charge utile.
const emit = defineEmits({
  ajouterPanier(payload) {
    return payload.id != null && payload.quantite > 0
  },
  survol: null, // pas de validation
})

function ajouter(quantite) {
  emit('ajouterPanier', { id: props.produit.id, quantite })
}
</script>

<template>
  <article @mouseenter="emit('survol', produit.id)">
    <h3>{{ produit.nom }}</h3>
    <p :class="{ prix: true, 'prix-promo': enPromotion }">{{ produit.prix }} €</p>
    <button @click="ajouter(1)">Ajouter au panier</button>
  </article>
</template>
vue
<!-- Composant parent -->
<script setup>
import { ref } from 'vue'
import CarteProduit from './components/CarteProduit.vue'

const produits = ref([
  { id: 1, nom: 'Clavier mécanique', prix: 89.9 },
  { id: 2, nom: 'Souris sans fil', prix: 34.5 },
])
const panier = ref([])

function surAjoutPanier({ id, quantite }) {
  panier.value.push({ id, quantite })
}
</script>

<template>
  <CarteProduit
    v-for="p in produits"
    :key="p.id"
    :produit="p"
    :en-promotion="p.prix < 50"
    @ajouter-panier="surAjoutPanier"
    @survol="(id) => console.log('survol produit', id)"
  />
  <p>{{ panier.length }} article(s) dans le panier</p>
</template>
vue
<!-- v-model personnalisé : sucre syntaxique combinant une prop et un emit -->
<!-- components/ChampMontant.vue -->
<script setup>
defineProps({ modelValue: { type: Number, required: true } })
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    type="number"
    :value="modelValue"
    @input="emit('update:modelValue', Number($event.target.value))"
  />
</template>

<!-- Utilisation : <ChampMontant v-model="prix" /> -->
<!-- équivaut à : <ChampMontant :model-value="prix" @update:model-value="prix = $event" /> -->
vue
<!-- v-model avec plusieurs valeurs liées (arguments nommés) -->
<!-- components/PlageDePrix.vue -->
<script setup>
defineProps({ min: Number, max: Number })
defineEmits(['update:min', 'update:max'])
</script>

<template>
  <input type="number" :value="min" @input="$emit('update:min', +$event.target.value)" />
  <input type="number" :value="max" @input="$emit('update:max', +$event.target.value)" />
</template>

<!-- <PlageDePrix v-model:min="prixMin" v-model:max="prixMax" /> -->

Résumé

  • Les props descendent, les événements remontent : un flux de données unidirectionnel, prévisible.
  • defineProps/defineEmits avec validation détectent les erreurs d'intégration dès le développement.
  • v-model sur un composant custom n'est qu'un raccourci pour :model-value + @update:model-value.
  • Ne jamais muter directement une prop dans l'enfant : elle doit rester en lecture seule côté enfant.

Exercices pratiques

1 disponible
1

Mission : réparer un panier qui se corrompt tout seul

Objectif : Diagnostiquer une mutation directe de prop dans un composant enfant, puis la remplacer par un flux emit conforme.

Contexte

Dans CarteProduit.vue, un développeur a ajouté un bouton "Appliquer une remise de 10%" qui exécute directement props.produit.prix = props.produit.prix * 0.9. Ça fonctionne visuellement, mais depuis cet ajout, le prix affiché dans D'AUTRES composants qui utilisent le même objet produit change aussi de façon inattendue, et personne ne comprend d'où vient la remise appliquée deux fois sur certains produits.

Résoudre l’exercice →