Une interface, des dizaines de petites pièces
Votre fichier App.vue a grossi. La liste des notes, la barre de recherche, le bouton favori, la fiche détaillée : tout est entassé dans un seul gabarit de deux cents lignes. Le moindre changement devient risqué, et impossible de réutiliser la « carte de note » ailleurs. La solution tient en un mot : les composants. On découpe l’interface en morceaux autonomes, chacun dans son fichier, et on les assemble comme des briques.
Mais découper ne suffit pas : ces briques doivent communiquer. Comment le parent transmet-il une note à la carte qui doit l’afficher ? Comment la carte prévient-elle le parent qu’on a cliqué sur « supprimer » ? La réponse, c’est le couple props / events : les données descendent par les props, les actions remontent par les events. Dans ce tutoriel, vous construisez la carte de note réutilisable de NoteFlux, son bouton favori et son édition de titre par v-model personnalisé.
Ce que vous allez apprendre
- Créer un composant enfant et l’utiliser dans un parent.
- Faire descendre des données par les props avec
defineProps, en les typant et en leur donnant des valeurs par défaut. - Faire remonter des actions par les events avec
defineEmits. - Créer un
v-modelpersonnalisé sur un composant avecdefineModel. - Distribuer du contenu libre dans un composant grâce aux slots.
Ce que vous allez construire
Un composant NoteCard qui affiche une note, expose un bouton favori et un bouton supprimer, et permet d’éditer le titre en place. Le parent — la liste de NoteFlux — lui transmet la note par une prop, et réagit aux événements toggle-favori et supprimer émis par la carte. Résultat : une liste où chaque carte est une pièce indépendante, testable et réutilisable.
Prérequis
- Un projet Vue 3 fonctionnel (voir la création avec
npm create vue@latest). - Avoir compris la réactivité : ref, reactive, computed et watch. On réutilise le tableau de notes du tutoriel précédent.
- ⏱️ Temps estimé : ~45 minutes.
Test express : si vous savez déclarer un ref et l’afficher dans un gabarit, vous êtes prêt.
Étape 1 — Créer et utiliser un premier composant
Un composant est un fichier .vue comme les autres. Créons src/components/NoteCard.vue avec, pour l’instant, un affichage figé. L’objectif est d’abord de voir un composant enfant s’afficher dans un parent, avant d’y faire circuler des données.
<!-- src/components/NoteCard.vue -->
<script setup>
</script>
<template>
<article class="carte">
<h3>Titre de la note</h3>
</article>
</template>
Dans le parent, on l’importe et on l’utilise comme une balise. Avec <script setup>, un composant importé est automatiquement disponible dans le gabarit — pas besoin de l’enregistrer explicitement :
<script setup>
import NoteCard from './components/NoteCard.vue'
</script>
<template>
<NoteCard />
<NoteCard />
</template>
Deux cartes identiques s’affichent. C’est trivial, mais le mécanisme est posé : un composant est une unité que l’on instancie autant de fois qu’on veut. Reste à lui donner des données différentes à chaque fois.
✅ Point d’étape — Vous devez voir deux cartes « Titre de la note ». Si vous avez l’erreur « Failed to resolve component », vérifiez le chemin d’import et la casse exacte du nom de fichier.
Étape 2 — Faire descendre des données par les props
Une prop est une entrée du composant : une donnée que le parent lui fournit. On les déclare avec la macro defineProps, disponible sans import dans <script setup>. Donnons à NoteCard une prop note :
<!-- NoteCard.vue -->
<script setup>
const props = defineProps({
note: { type: Object, required: true },
surligne: { type: Boolean, default: false }
})
</script>
<template>
<article class="carte" :class="{ surligne }">
<h3>{{ note.titre }}</h3>
<span v-if="note.favori">★ favori</span>
</article>
</template>
On déclare ici deux props avec leur type, le caractère obligatoire ou une valeur par défaut. Dans le gabarit, on accède directement à note et surligne ; dans le script, à props.note. Le parent les passe comme des attributs, en utilisant v-bind (le :) pour transmettre une vraie valeur JavaScript plutôt qu’une chaîne :
<!-- parent -->
<NoteCard
v-for="note in notesFiltrees"
:key="note.id"
:note="note"
:surligne="note.id === noteActiveId"
/>
Point crucial : les props sont en lecture seule. L’enfant ne doit jamais modifier props.note directement. Le flux est descendant — la donnée appartient au parent, l’enfant ne fait que l’afficher. Si l’enfant veut changer quelque chose, il doit le demander au parent. C’est l’objet de l’étape suivante.
✅ Point d’étape — Chaque carte affiche maintenant le vrai titre de sa note, et l’étoile « favori » apparaît sur la note de Diop. Si toutes les cartes affichent la même note, vérifiez que vous utilisez
:note="note"(avec les deux-points) et pasnote="note".
Étape 3 — Faire remonter des actions par les events
L’utilisateur clique sur l’étoile d’une carte pour la mettre en favori. Mais c’est le parent qui détient le tableau des notes : c’est donc à lui de modifier la donnée. L’enfant se contente de signaler l’intention en émettant un événement, que le parent écoute. On déclare les événements possibles avec defineEmits :
<!-- NoteCard.vue -->
<script setup>
const props = defineProps({ note: { type: Object, required: true } })
const emit = defineEmits(['toggle-favori', 'supprimer'])
</script>
<template>
<article class="carte">
<h3>{{ note.titre }}</h3>
<button @click="emit('toggle-favori', note.id)">
{{ note.favori ? '★' : '☆' }}
</button>
<button @click="emit('supprimer', note.id)">Supprimer</button>
</article>
</template>
L’enfant émet toggle-favori avec l’identifiant de la note en argument. Le parent écoute cet événement avec la directive v-on (le @) et agit sur sa propre donnée :
<!-- parent -->
<NoteCard
v-for="note in notesFiltrees"
:key="note.id"
:note="note"
@toggle-favori="basculerFavori"
@supprimer="supprimerNote"
/>
function basculerFavori(id) {
const note = notes.value.find(n => n.id === id)
if (note) note.favori = !note.favori
}
function supprimerNote(id) {
notes.value = notes.value.filter(n => n.id !== id)
}
Le cycle est complet et parfaitement traçable : la donnée descend par :note, l’action remonte par @toggle-favori, le parent modifie le tableau, et la réactivité redessine la carte avec la nouvelle étoile. À aucun moment l’enfant n’a touché à une donnée qui ne lui appartient pas. Cette discipline — données vers le bas, événements vers le haut — est ce qui rend une application Vue prévisible même à grande échelle.
✅ Point d’étape — Cliquez sur l’étoile d’une carte : elle doit basculer pleine/vide, et le compteur de favoris (du tutoriel réactivité) suivre. Cliquez sur « Supprimer » : la carte disparaît. Si rien ne se passe, vérifiez que le nom de l’événement est identique côté
emitet côté@.
Étape 4 — Éditer le titre avec un v-model personnalisé
On veut maintenant pouvoir renommer une note directement dans la carte. Le v-model n’est pas réservé aux <input> : on peut en exposer un sur n’importe quel composant. Depuis Vue 3.4, la macro defineModel rend cela trivial. Elle renvoie un ref dont les lectures et écritures sont synchronisées avec le parent :
<!-- NoteCardEditable.vue -->
<script setup>
const titre = defineModel('titre')
</script>
<template>
<input v-model="titre" placeholder="Titre de la note">
</template>
Le composant déclare un modèle nommé titre. Dans son gabarit, il le lie à un champ par v-model="titre" ; toute frappe met à jour titre.value, qui se propage automatiquement au parent. Côté parent, on branche le modèle comme sur un input natif :
<!-- parent -->
<NoteCardEditable v-model:titre="noteActive.titre" />
Plus besoin de déclarer manuellement une prop modelValue et un événement update:modelValue comme avant : defineModel s’en charge. C’est l’une des simplifications les plus appréciées de Vue 3 récent. On peut exposer plusieurs v-model sur un même composant en les nommant (v-model:titre, v-model:couleur), ce qui est idéal pour un formulaire d’édition complet.
✅ Point d’étape — Tapez dans le champ d’édition : le titre affiché ailleurs dans la page doit changer en temps réel. Si l’édition ne remonte pas, vérifiez que le parent utilise bien
v-model:titreavec le même nom que dansdefineModel('titre').
Étape 5 — Distribuer du contenu avec les slots
Parfois, on veut qu’un composant accueille du contenu libre décidé par le parent — par exemple une carte générique dont le parent remplit le corps. C’est le rôle des slots. Le composant marque un emplacement avec <slot>, et le parent y injecte ce qu’il veut :
<!-- Carte.vue -->
<template>
<article class="carte">
<header><slot name="titre">Sans titre</slot></header>
<div class="corps"><slot></slot></div>
</article>
</template>
<!-- parent -->
<Carte>
<template #titre>Facture client Diop</template>
<p>Montant en attente de règlement.</p>
</Carte>
Le slot par défaut reçoit tout le contenu non nommé ; les slots nommés (ici #titre) ciblent un emplacement précis. Le texte « Sans titre » sert de contenu de repli si le parent ne fournit rien. Les slots sont la clé des composants vraiment réutilisables : un même composant d’habillage peut envelopper des contenus radicalement différents.
Les slots peuvent aussi fournir des données au parent : ce sont les slots avec portée (scoped slots). Le composant expose une valeur sur son <slot>, et le parent la récupère pour décider du rendu. C’est ce qui permet d’écrire une liste générique tout en laissant le parent maître de l’affichage de chaque élément :
<!-- ListeNotes.vue -->
<template>
<ul>
<li v-for="note in notes" :key="note.id">
<slot :note="note">{{ note.titre }}</slot>
</li>
</ul>
</template>
<!-- parent : on recoit la note exposee par le slot -->
<ListeNotes :notes="notesFiltrees" v-slot="{ note }">
<strong>{{ note.titre }}</strong> — {{ note.favori ? '★' : '' }}
</ListeNotes>
Le composant gère la boucle et la structure ; le parent décide à quoi ressemble chaque ligne. C’est ce patron qui permet aux bibliothèques de tableaux ou de listes de rester totalement personnalisables sans imposer leur mise en forme.
Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| « Failed to resolve component » | Composant non importé, ou mauvaise casse du nom | Importer dans <script setup> ; respecter la casse du fichier |
La prop reçoit la chaîne "note" au lieu de l’objet |
Oubli du : (v-bind) devant l’attribut |
Écrire :note="note", pas note="note" |
| Avertissement « Avoid mutating a prop directly » | L’enfant modifie une prop | Émettre un event ; laisser le parent muter sa donnée |
| L’événement n’est jamais reçu | Nom différent entre emit('x') et @x, ou casse (kebab-case attendu) |
Utiliser le même nom en kebab-case des deux côtés |
Le v-model personnalisé ne remonte pas |
Nom du modèle incohérent | Aligner defineModel('titre') et v-model:titre |
Réalités du terrain
Découper en composants a un effet direct sur la performance perçue, surtout sur les connexions lentes. Un composant lourd — un éditeur riche, une visionneuse de pièces jointes — peut être chargé à la demande avec defineAsyncComponent, qui ne télécharge son code que lorsqu’il s’affiche réellement. Sur la fiche d’une note, l’éditeur n’est chargé qu’au clic sur « Modifier », et la page initiale reste légère. C’est le même principe que le découpage par route, appliqué au composant. Combiné à des cartes bien isolées, cela permet à NoteFlux de rester fluide même sur un téléphone modeste avec une bande passante réduite, parce qu’on ne paie le coût d’un morceau d’interface que lorsqu’il sert vraiment.
Récapitulatif
Vous savez désormais découper une interface en composants et les faire dialoguer : les props font descendre les données (en lecture seule), les events font remonter les actions, defineModel crée des liaisons bidirectionnelles propres, et les slots permettent d’injecter du contenu libre. La carte de note de NoteFlux est maintenant une brique autonome, réutilisable et testable. Cette circulation à sens unique est le principe d’architecture le plus important de Vue.
Aide-mémoire
| Élément | Rôle |
|---|---|
defineProps({...}) |
Déclarer les entrées (données) du composant — lecture seule |
defineEmits([...]) |
Déclarer les événements que le composant peut émettre |
emit('nom', charge) |
Émettre un événement vers le parent avec un argument |
defineModel('nom') |
Exposer un v-model personnalisé (Vue 3.4+) |
:prop="valeur" / @event="fn" |
Passer une donnée / écouter un événement côté parent |
<slot> / #nom |
Emplacement de contenu libre / slot nommé |
defineAsyncComponent |
Charger un composant à la demande |
À vous de jouer
Ajoutez à NoteCard une prop compacte (booléenne) qui masque l’étoile et la date quand elle vaut true, et un événement dupliquer qui demande au parent de créer une copie de la note. Câblez le tout dans la liste.
Voir une solution
// NoteCard.vue
const props = defineProps({
note: { type: Object, required: true },
compacte: { type: Boolean, default: false }
})
const emit = defineEmits(['toggle-favori', 'supprimer', 'dupliquer'])
// parent
function dupliquerNote(id) {
const src = notes.value.find(n => n.id === id)
if (src) notes.value.push({ ...src, id: Date.now(), titre: src.titre + ' (copie)' })
}
Tutoriels frères
- Réactivité : ref, reactive, computed, watch — l’état que les composants manipulent.
- Gérer l’état avec Pinia — éviter de faire transiter les données par dix niveaux de props.
Pour aller plus loin
- 🔝 Retour au guide : Vue 3 et la Composition API
- Les props — documentation officielle.
- Les événements de composant — la référence sur
defineEmits. - v-model de composant — l’usage complet de
defineModel.
FAQ
Props ou Pinia pour partager des données ?
Les props suffisent tant que la donnée circule entre un parent et ses enfants proches. Dès qu’elle doit traverser de nombreux niveaux ou être lue par des composants éloignés, un store Pinia évite le « tunnel de props ». C’est l’objet du tutoriel suivant.
Faut-il typer les props avec TypeScript ?
Si votre projet est en TypeScript, oui : defineProps accepte une syntaxe basée sur les types qui apporte autocomplétion et vérification. En JavaScript, la déclaration par objet (avec type et default) reste très utile pour documenter et valider à l’exécution.
Quelle convention de nommage pour les événements ?
Émettez et écoutez en kebab-case (toggle-favori). Vue est tolérant, mais cette convention évite les surprises et reflète l’usage du gabarit HTML, insensible à la casse.
defineProps et defineEmits doivent-ils être importés ?
Non. Ce sont des macros de compilation disponibles automatiquement dans <script setup>. Les importer provoquerait même un avertissement.
Comment un enfant peut-il exposer une méthode au parent ?
Par défaut, un composant en <script setup> est fermé : le parent ne voit rien de son intérieur. Pour rendre une méthode ou une valeur accessible via une référence de gabarit, on utilise la macro defineExpose. On l’emploie avec parcimonie — la communication normale passe par les props et les events ; defineExpose est réservé aux cas où le parent doit déclencher une action interne, comme remettre à zéro un formulaire enfant.