Les composants
Introduction
Un composant est un bloc réutilisable qui regroupe un gabarit avec de la logique (<script setup>), une interface (<template>) et parfois du style (<style scoped>). Plutôt que d'empiler tout le code dans App.vue, on découpe l'application en petits éléments clairs et autonomes. On gagne en lisibilité, réutilisation et testabilité.
Les composants évitent de réécrire la même structure et la même logique à chaque utilisation. Chaque élément est défini dans son propre fichier puis importé par son composant parent. L'assemblage progresse en cascade jusqu'aux composants de premier niveau intégrés dans le parent principal App.vue.
Voici quelques exemples :
- En-tête dans un composant dédié (Header.vue)
- Carte d'information avec son propre gabarit (InfoCard.vue)
- Bouton compteur avec état local (CounterButton.vue)
- Pied de page isolé pour favoriser la réutilisation (Footer.vue)
Créer et utiliser un composant
Un composant vit dans un fichier unique et dans un dossier clair afin de rester facile à trouver et à réutiliser. Le nom du fichier suit une convention lisible en PascalCase, ce qui favorise des imports cohérents et une navigation rapide. L'application assemble ensuite ces briques dans App.vue sans dupliquer la logique.
Le flux de travail reste simple :
-
Créer les composants dans le dossier "src/components".
ex.: "src/components/MonComposant.vue". -
Importer chaque composant dans le parent qui en a besoin.
ex.: import MonComposant from './components/MonComposant.vue';. -
Employer dans l'interface (<template>) du parent comme une balise personnalisée.
ex.: <MonComposant />. - Garder la logique locale à l'intérieur du composant et on limite les échanges aux données et aux événements nécessaires.
Exemple d'un composant d'incrémentation
Dans l'exemple suivant, nous allons créer un composant d'incrémentation qui (logique <script> et affichage <template>). Le fichier se place dans src/components/CounterButton.vue puis il est importé dans le composant racine src/App.vue et utilisé comme balise personnalisée <CounterButton />. Deux instances seront affichées afin de montrer que chaque composant conserve un état privé indépendant.
Composant CounterButton.vue (src/components/CounterButton.vue) :
<script setup>
import { ref } from 'vue';
// État interne du composant.
const count = ref(0);
// Petite fonction locale pour incrémenter.
const incrementer = () => { count.value++ };
</script>
<template>
<button @click="incrementer">
Vous avez cliqué {{ count }} fois
</button>
</template>
Utilisation dans le composant racine App.vue (src/App.vue) :
<script setup>
// Import du composant CounterButton.
import CounterButton from './components/CounterButton.vue';
</script>
<template>
<h2>Démo des composants</h2>
<CounterButton />
<CounterButton />
</template>
Le composant CounterButton maintient un état privé avec la donnée réactive count. Chaque instance possède sa propre valeur, ce qui signifie que les clics sur la première ne modifient pas la seconde. La fonction incrementer() agit uniquement sur l'état du composant qui l'exécute. Aucune variable globale, aucun partage involontaire. On obtient un comportement prévisible et facile à tester.
Dans App.vue, le parent importe le fichier puis utilise le composant deux fois. Le parent assemble l'interface et laisse l'enfant gérer sa logique interne. Cette séparation améliore la lisibilité, réduit les effets de bord et facilite la réutilisation. Si l'on supprime une instance, l'autre continue de fonctionner sans impact, ce qui illustre l'isolation fournie par les composants.
Organisation et conventions
Des conventions de nommage et d'organisation assurent une arborescence nette et des imports lisibles. Elles précisent où créer les fichiers, comment les nommer et comment structurer chaque composant afin de faciliter la maintenance et la réutilisation :
-
Dossier de base : src/components
src ├── components │ ├── Header.vue │ ├── InfoCard.vue │ ├── CounterButton.vue │ └── Footer.vue ├── App.vue └── main.js - Un fichier par composant : Nom de fichier écrit en PascalCase (ex.: MonComposant.vue).
- Nom du fichier aligné avec le nom du composant : Imports plus lisibles et recherche facilitée (ex.: Nom de fichier MonComposant.vue pour le composant <MonComposant />).
- Structure du fichier : <script setup>, puis <template>, puis éventuellement <style scoped>.
- Responsabilité unique : Un objectif précis et limité par composant pour améliorer la lisibilité, favoriser la réutilisation et simplifier les tests.
Utiliser les props pour passer des données du parent à l'enfant
Une prop est une donnée d'entrée fournie par le composant parent et lue par l'enfant en lecture seule. Côté parent, on peut soit écrire une valeur littérale pour un contenu fixe, soit créer une liaison avec v-bind (ou son raccourci :) pour que l'enfant se mette automatiquement à jour lorsque cette valeur change.
Dans le composant enfant, les props se déclarent dans la partie <script setup> avec la fonction defineProps(). Cette fonction reçoit la liste ou le schéma des props attendues et retourne un objet props que l'on peut utiliser dans le script et dans le template pour lire les valeurs envoyées par le parent.
Exemple
L'exemple suivant illustre trois façons de passer des props à un composant enfant. Deux instances du composant Utilisateur sont insérées dans le template du parent pour comparer les cas d'usage. Les noms sont fournis en valeurs littérales et restent fixes :
- La première instance transmet aussi genre comme chaîne littérale, cette valeur restera donc toujours figée.
- La deuxième instance lie genre à une donnée réactive du parent avec v-bind, puis un bouton dans le parent modifie cette donnée et l'affichage de l'enfant se met à jour.
Composant enfant BadgeUtilisateur.vue (src/components/BadgeUtilisateur.vue) :
<script setup>
// Déclaration des props à l'aide d'un tableau passé en argument à la fonction "defineProps()".
// Attention aux noms des props :
// Les attributs écrits en kebab-case dans le parent
// correspondent à des noms en camelCase ici
// Ex.: parent → enfant : "nom-utilisateur" → "nomUtilisateur"
const props = defineProps(['nomUtilisateur', 'genre'])
</script>
<template>
<article class="badge">
<h4>{{ props.nomUtilisateur }}</h4>
<p>Genre : {{ props.genre }}</p>
</article>
</template>
Notez que defineProps() est fourni directement par Vue en tant que macro de compilation. Il ne faut donc pas l'importer avec import { defineProps } from 'vue'. Au moment de la compilation, Vue remplace cette macro par le code nécessaire pour brancher les props, ce qui explique que l'appel fonctionne sans import explicite.
Composant parent App.vue (src/App.vue) :
<script setup>
import { ref } from 'vue';
import BadgeUtilisateur from './components/BadgeUtilisateur.vue';
const genreDeBob = ref('homme');
const basculerGenreDeBob = () =>
{
genreDeBob.value = genreDeBob.value === 'homme' ? 'femme' : 'homme';
}
</script>
<template>
<h2>Liste des utilisateurs</h2>
<!--
Valeur littérale non liée. La prop 'genre' reçoit une chaîne fixe.
-->
<BadgeUtilisateur nom-utilisateur="Stéphanie" genre="femme" />
<!--
Liaison avec v-bind (raccourci :). La prop "genre" reçoit la valeur réactive de "genreDeBob".
Si "genreDeBob" change dans le parent, l'enfant se met à jour automatiquement.
-->
<BadgeUtilisateur nom-utilisateur="Bob" :genre="genreDeBob" />
<!--
Petite action pour montrer que la donnée réactive du parent met à jour l'enfant
-->
<button @click="basculerGenreDeBob">Basculer genre de Bob</button>
</template>
Notez que dans le composant parent, on écrit les noms d'attributs en kebab-case alors que le composant enfant déclare ces mêmes props en camelCase.
Configurer le type et les options des props
Jusqu'à présent, nous avons vu comment déclarer les props en passant un tableau de noms (en camelCase) à la fonction defineProps() du composant enfant.
Si l'on veut ajouter des options aux props, on n'utilise plus un tableau mais un objet de schéma passé à defineProps(). Chaque clé est le nom de la prop en camelCase et chaque valeur est un objet d'options qui décrit ce que le parent peut transmettre type, required, default, validator. Cette déclaration documente le contrat d'entrée du composant et active des avertissements utiles en développement.
Dans le schéma de defineProps(), aucune des options n'est obligatoire. Seul le nom de la prop est requis pour l'annoncer. Tous les champs ci-dessous sont facultatifs et indépendants, avec des effets complémentaires :
- type : Définit le ou les constructeurs attendus (ex.: type: String ou type: [String, Number]).
- required : impose que la prop soit fournie par le parent. Si elle est absente, Vue émet un avertissement en mode développement.
-
default : Fournit une valeur de repli lorsque le parent n'envoie rien.
Pour les tableaux (Array) ou les objets (Object),
il faut utiliser une fonction fabrique qui retourne une nouvelle instance :
- Tableau par défaut : default: () => []
- Objet par défaut : default: () => ({})
- validator : garde-fou uniquement en développement. Permet de définir une fonction personnalisée qui reçoit la valeur de la prop et qui doit retourner true si elle est acceptable et false dans le cas contraire. En cas d'échec, Vue affiche un avertissement en mode développement mais la valeur est quand même transmise au composant.
Exemple
Repartons de la liste des utilisateurs. Nous allons configurer les props afin de préciser le type attendu, indiquer celles qui sont requises et définir des valeurs par défaut. Le but est d'améliorer la lisibilité du code et la robustesse du composant.
Comme nous ajoutons des options, nous ne passons plus un tableau de noms en argument à la fonction defineProps() mais un objet de schéma. Chaque propriété porte le nom de la prop et contient un objet d'options qui décrit son comportement (type, required, default, validator).
Dans cette version de la démonstration, la deuxième instance du composant BadgeUtilisateur n'envoie pas la prop genre. Le composant enfant applique alors la valeur par défaut définie dans defineProps().
Composant enfant BadgeUtilisateur.vue (src/components/BadgeUtilisateur.vue) :
<script setup>
// Props déclarées côté enfant :
// - "nomUtilisateur" : chaîne, requise
// - "genre" : chaîne, optionnelle, valeur par défaut "Non communiqué"
const props = defineProps({
nomUtilisateur: { type: String, required: true },
genre: { type: String, default: 'Non communiqué' }
});
</script>
<template>
<article class="badge">
<h4>{{ props.nomUtilisateur }}</h4>
<p>Genre : {{ props.genre }}</p>
</article>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import BadgeUtilisateur from './components/BadgeUtilisateur.vue';
</script>
<template>
<h2>Liste des utilisateurs</h2>
<!--
Prop "genre" non fournie par le parent.
L'enfant applique la valeur par défaut déclarée dans "defineProps()".
-->
<BadgeUtilisateur nom-utilisateur="Claude" />
</template>
Props avec plusieurs types et forme raccourcie
Une prop peut accepter plusieurs types de valeurs. Par exemple, un composant peut tolérer une chaîne de caractères ou un nombre pour la même prop. Dans la configuration détaillée, on fournit alors un tableau de constructeurs à type afin que Vue accepte l'un ou l'autre type.
<script setup>
const props = defineProps({
// La prop "valeur" accepte une chaîne OU un nombre
valeur: { type: [String, Number] }
});
</script>
Quand seule l'option type est configurée, Vue accepte une écriture plus compacte. On place directement le constructeur ou le tableau de constructeurs à la place de l'objet de configuration.
<script setup>
const props = defineProps({
// Prop qui accepte uniquement une chaîne
titre: String,
// Prop qui accepte une chaîne OU un nombre
valeur: [String, Number]
});
</script>
Cette forme compacte reste pratique tant que l'on se limite au type. Dès que l'on souhaite aussi préciser required, default ou validator, on doit revenir à la forme complète avec un objet d'options.
Vérifier les valeurs des props avec validator
Le champ validator permet de vérifier que la valeur reçue respecte une règle métier simple. La fonction reçoit la valeur de la prop et doit retourner true si elle est acceptable ou false sinon. Vue affiche alors un avertissement en mode développement, mais la valeur est quand même transmise au composant.
Dans l'exemple suivant, le composant enfant attend un rôle utilisateur parmi trois valeurs autorisées. Si le parent envoie autre chose, un avertissement apparaît dans la console.
Composant enfant BadgeRole.vue (src/components/BadgeRole.vue) :
<script setup>
const props = defineProps({
role: {
type: String,
required: true,
// N'autorise que trois valeurs : "admin", "auteur" ou "lecteur".
validator: (valeur) => ['admin', 'auteur', 'lecteur'].includes(valeur)
}
});
</script>
<template>
<article class="badge-role">
<p>Rôle : {{ props.role }}</p>
</article>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import BadgeRole from './components/BadgeRole.vue';
</script>
<template>
<h2>Tests des props avec validator</h2>
<!--
Exemple valide : "role" fait partie de la liste autorisée.
-->
<BadgeRole role="admin" />
<!--
Exemple invalide volontaire :
"role" reçoit "super-admin" qui ne fait pas partie des valeurs autorisées.
Vue affichera un avertissement en console en mode développement,
mais le composant sera quand même rendu.
-->
<BadgeRole role="super-admin" />
</template>
Cet exemple montre que validator sert surtout de garde-fou en développement. Il aide à repérer rapidement les mauvaises utilisations d'un composant sans bloquer l'affichage.
Interdiction de modifier les props dans le composant enfant
Dans Vue, le parent est la source de vérité, c'est-à-dire l'endroit où l'état est créé, détenu et mis à jour. L'enfant reçoit cet état via les props en lecture seule. Chercher à écrire dans une prop depuis l'enfant ne met pas à jour le parent, ne déclenche pas de nouveau rendu et affiche un avertissement en développement. Cette règle évite les états qui divergent entre parent et enfant et rend le débogage plus simple.
Exemples
Les exemples ci-dessous visent le même objectif, afficher la valeur en majuscules sans modifier la prop d'origine. On commence par un anti-exemple qui tente une écriture directe et échoue. Viennent ensuite deux solutions correctes, la première transforme uniquement l'affichage avec une computed(), la seconde édite un ref local synchronisé avec la prop.
Anti-exemple : Écriture directe dans une prop
Composant enfant TitreMajusculesDirect.vue (src/components/TitreMajusculesDirect.vue) :
<!-- Mauvaise pratique : tentative d'écriture dans une prop -->
<script setup>
// Le composant reçoit un titre depuis le parent.
const props = defineProps({ titre: String, default: '' });
// Essaie de remplacer la prop par sa version en majuscules.
// Avertissement en développement et aucune mise à jour du parent.
props.titre = props.titre.toUpperCase();
</script>
<template>
<h3>{{ props.titre }}</h3>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import TitreMajusculesDirect from './components/TitreMajusculesDirect.vue';
import { ref } from 'vue';
const titre = ref('Bonjour');
const basculerTitre = () =>
{
titre.value = titre.value == 'Bonjour' ? 'Aurevoir' : 'Bonjour';
}
</script>
<template>
<TitreMajusculesDirect :titre="titre" />
<button @click="basculerTitre">Basculer titre</button>
</template>
Cette version échoue car les props exposées dans l'enfant sont des valeurs en lecture seule. L'affectation est bloquée, l'état du parent ne change pas et l'interface ne se ré-affiche pas.
Approche sûre : Propriétés calculées sur la prop
<script setup>
import { computed } from 'vue';
const props = defineProps({ titre: String, default: '' });
// Valeur dérivée pour l'interface, sans toucher à la prop.
const titreMajuscules = computed(() => props.titre.toUpperCase());
</script>
<template>
<h3>{{ titreMajuscules }}</h3>
<p>Original : {{ props.titre }}</p>
</template>
Cette approche fonctionne car la transformation est limitée à l'affichage. La prop d'origine n'est pas modifiée et reste pilotée par le parent.
Approche sûre : État local réactif et observateur sur la prop
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
titre: { type: String, default: '' }
});
// État local éditable, initialisé depuis la prop.
const titreLocal = ref(props.titre);
// Si le parent change la prop "titre", on met à jour la version locale
// en appliquant ici une transformation en majuscules pour l'exemple.
watch(
() => props.titre,
nouveauTitre => {
titreLocal.value = nouveauTitre.toUpperCase();
},
{ immediate: true }
);
</script>
<template>
<input
v-model="titreLocal"
placeholder="Modifier le titre local en majuscules"
/>
<p>Prop d'origine : {{ props.titre }}</p>
<p>État local (majuscules) : {{ titreLocal }}</p>
</template>
On travaille sur un ref local qui sert de zone d'édition. Si le parent met à jour la prop, un watch() resynchronise l'état local titreLocal. À ce stade, modifier le champ n'affecte pas le parent, ce qui respecte la règle des props en lecture seule.
Notez que si l'objectif est de transmettre réellement la nouvelle valeur au parent, c'est possible. L'enfant n'édite pas la prop, il signale l'intention avec emit ou met en place un v-model personnalisé. La section suivante détaillera ces deux mécanismes.
Utiliser les emits pour communiquer du composant enfant vers le parent
Dans Vue, comme vu dans la section sur les props, un composant enfant ne modifie pas directement l'état de son parent. Quand l'enfant veut le faire réagir à une action utilisateur, il envoie un événement que le parent écoute puis traite. On parle alors d'emits pour désigner ces événements.
On obtient ainsi un schéma simple pour la circulation des informations. Les props font descendre les données du parent vers l'enfant et les emits envoyés par l'enfant font remonter vers le parent les intentions de l'utilisateur afin qu'il mette son état à jour.
Déclarer les événements autorisés avec defineEmits()
Quand un composant enfant souhaite émettre un événement vers son parent, il doit d'abord déclarer quels événements il est autorisé à envoyer. Cette déclaration se fait avec la fonction defineEmits() dans la partie <script setup> du composant enfant.
Dans sa forme la plus simple, defineEmits() reçoit un tableau de noms d'événements. Chaque nom correspond à un événement que l'enfant pourra émettre vers son parent.
Exemple d'utilisation de defineEmits()
Reprenons maintenant l'exemple du bouton de suppression dans le composant enfant. Celui-ci contacte son parent pour lui demander de supprimer l'élément correspondant à l'identifiant transmis dans l'événement.
Composant enfant TodoItem.vue (src/components/TodoItem.vue) :
<script setup>
// Déclare les props reçues depuis le parent.
const props = defineProps({
id: { type: Number, required: true },
texte: { type: String, required: true }
});
// (EMIT) Déclare les événements que le composant enfant est autorisé à émettre.
const emit = defineEmits(['demanderSuppression']);
// (EMIT) Fonction qui émet l'événement "demanderSuppression" vers le parent
// en lui transmettant l'identifiant de l'élément à supprimer.
const demanderSuppression = () =>
{
emit('demanderSuppression', props.id);
};
</script>
<template>
<li>
<span>{{ props.texte }}</span>
<!--
(EMIT) Dans le template de l'enfant, on appelle la fonction qui émet l'événement
-->
<button type="button" @click="demanderSuppression">
Supprimer
</button>
</li>
</template>
Tout comme pour la fonction defineProps(), la fonction defineEmits() est une macro de compilation fournie directement par Vue. Aucun import n'est nécessaire, on n'écrit donc pas import { defineEmits } from 'vue'.
Composant parent App.vue (src/App.vue) :
<script setup>
import { ref } from 'vue';
import TodoItem from './components/TodoItem.vue';
// État géré par le parent.
const taches = ref([
{ id: 1, texte: 'Préparer la réunion' },
{ id: 2, texte: 'Réviser le chapitre Vue' },
{ id: 3, texte: 'Envoyer le compte rendu' }
]);
// Fonction appelée quand l'enfant émet "demanderSuppression".
// Elle met à jour l'état du parent en retirant l'élément concerné.
const supprimerTache = (id) =>
{
taches.value = taches.value.filter(t => t.id !== id);
}
</script>
<template>
<section>
<h2>Tâches</h2>
<ul>
<!--
(EMIT) Le parent ÉCOUTE l'événement émis par l'enfant avec "@demanderSuppression"
et APPELLE sa propre fonction "supprimerTache()" en récupérant l'id transmis.
-->
<TodoItem
v-for="t in taches"
:key="t.id"
:id="t.id"
:texte="t.texte"
@demanderSuppression="supprimerTache"
/>
</ul>
</section>
</template>
Analyse de l'utilisation des emits dans cet exemple
Déclaration et émission d'un événement côté enfant
-
Déclarer l'événement et la fonction qui l'émet
<script setup> // ... du code ... // Déclare l'événement que l'enfant peut émettre vers le parent. const emit = defineEmits(['demanderSuppression']); // Lorsque la fonction est appelée, // elle émet l'événement "demanderSuppression" vers le parent // en lui transmettant l'identifiant reçu via les props. const demanderSuppression = () => emit('demanderSuppression', props.id); // ... du code ... </script> - Ajouter la directive d'événement qui déclenche la fonction intermédiaire demanderSuppression() :
<template>
<!-- ... du code ... -->
<!--
Au clic, il appelle la fonction "demanderSuppression"
qui émettra l'événement et l'id (via prop.id) concerné au parent.
-->
<button @click="demanderSuppression">Supprimer</button>
<!-- ... du code ... -->
</template>
Écoute et traitement de l'événement côté parent
-
Écouter l'événement émis par l'enfant :
<template> <!-- ... du code ... --> <!-- Le nom de l'événement déclaré dans defineEmits('demanderSuppression') devient automatiquement le nom à écouter dans le template du parent (ici "@demanderSuppression"). Quand cet événement est émis par l'enfant, la fonction "supprimerTache" du parent est appelée avec l'id transmis. --> <TodoItem v-for="t in taches" :key="t.id" :id="t.id" :texte="t.texte" @demanderSuppression="supprimerTache" /> <!-- ... du code ... --> </template> -
La fonction appelée quand l'enfant émet l'événement demanderSuppression
<script setup> // ... du code ... // Fonction exécutée quand l'enfant émet l'événement "@demanderSuppression". // Le paramètre "id" reçoit la valeur transmise par l'enfant // via emit('demanderSuppression', props.id) dans le composant TodoItem. // Elle met à jour l'état du parent en retirant l'élément concerné. const supprimerTache = (id) => { taches.value = taches.value.filter(t => t.id !== id) } // ... du code ... </script> -
L'affichage se met automatiquement à jour grâce à la réactivité de Vue :
En modifiant le tableau taches, Vue relance un rendu du composant parent. Le DOM est synchronisé sans qu'aucune manipulation manuelle ne soit nécessaire.
Synchroniser une donnée entre parent et enfant avec v-model
Lorsqu'on utilise v-model sur un élément HTML (ex. : <input>), Vue gère automatiquement la synchronisation de la valeur saisie dans le champ avec une donnée réactive du composant parent.
Vue permet d'utiliser v-model directement sur un composant enfant (ex. <ComposantEnfant v-model="donneeReactive" />). Cela met automatiquement en place un échange de données entre le parent et l'enfant.
- Le composant parent transmet automatiquement une prop nommée modelValue au composant enfant. Cette prop reçoit la valeur actuelle de la donnée réactive liée à la directive v-model dans le parent.
- Le composant parent écoute automatiquement un événement émis nommé update:modelValue. Cette écriture peut sembler étrange au début mais elle suit une règle simple le nom de l'événement est construit en ajoutant le préfixe update: devant le nom de la prop modelValue.
Autrement dit, v-model correspond simplement à une liaison préconfigurée entre une prop et un événement. Côté parent, tout est déjà câblé, il n'y a rien de plus à écrire. Côté enfant en revanche, il faut déclarer la prop modelValue et émettre l'événement update:modelValue pour que la synchronisation fonctionne.
Exemple
Dans l'exemple suivant, l'utilisateur saisit du texte dans un champ personnalisé qui appartient au composant enfant. À chaque frappe, la valeur est automatiquement renvoyée au composant parent. Celui-ci met alors son affichage à jour en temps réel, sans nécessiter de manipulation manuelle du DOM.
Composant enfant ChampTexte.vue (src/components/ChampTexte.vue) :
<!-- Composant enfant : src/components/ChampTexte.vue -->
<script setup>
const props = defineProps({
modelValue: { type: String, required: true }
});
const emit = defineEmits(['update:modelValue']);
// Quand l'utilisateur tape du texte (@input),
// on renvoie la nouvelle valeur vers le parent.
const mettreAJour = (event) =>
{
emit('update:modelValue', event.target.value);
};
</script>
<template>
<input
type="text"
:value="modelValue"
@input="mettreAJour"
>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import { ref } from 'vue';
import ChampTexte from './components/ChampTexte.vue';
const titre = ref('Hello Vue');
</script>
<template>
<ChampTexte v-model="titre" />
<p>Titre actuel : {{ titre }}</p>
</template>
Utiliser plusieurs v-model sur un même composant
Lorsque l'on utilise v-model sur un composant enfant sans préciser de nom, Vue applique une convention automatique. Le composant enfant doit recevoir une prop nommée modelValue, et renvoyer les modifications via un événement update:modelValue. Il s'agit du fonctionnement par défaut.
Il est cependant possible de personnaliser le nom de la donnée liée. En utilisant la syntaxe v-model:nomSouhaite, Vue attendra alors une prop nommée nomSouhaite et un événement update:nomSouhaite. Cette personnalisation devient très utile lorsque le composant enfant doit gérer plusieurs valeurs différentes, le parent peut alors utiliser plusieurs v-model dans la même balise pour synchroniser plusieurs champs distincts du composant.
Exemple
Dans l'exemple suivant, le composant enfant propose deux champs, un pour le prénom et un pour le nom. Lorsque l'utilisateur saisit du texte dans l'un des champs, la valeur correspondante est automatiquement renvoyée au composant parent grâce à deux v-model distincts. Le parent met alors à jour l'affichage du nom complet en temps réel, toujours sans aucune manipulation manuelle du DOM.
Composant enfant FormUtilisateur.vue (src/components/FormUtilisateur.vue) :
<script setup>
// Props reçues du parent.
const props = defineProps({
prenom: { type: String, required: true },
nom: { type: String, required: true }
});
// Déclaration des événements autorisés.
const emit = defineEmits([
'update:prenom',
'update:nom'
]);
// Quand l'utilisateur modifie le prénom, on renvoie la nouvelle valeur au parent.
const mettreAJourPrenom = (event) =>
{
emit('update:prenom', event.target.value);
};
// Quand l'utilisateur modifie le nom, on renvoie la nouvelle valeur au parent.
const mettreAJourNom = (event) =>
{
emit('update:nom', event.target.value);
};
</script>
<template>
<div>
<label>
Prénom
<input
type="text"
:value="prenom"
@input="mettreAJourPrenom"
>
</label>
<label>
Nom
<input
type="text"
:value="nom"
@input="mettreAJourNom"
>
</label>
</div>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import { ref } from 'vue';
import FormUtilisateur from './components/FormUtilisateur.vue';
// Données gérées par le parent.
const prenom = ref('Marie');
const nom = ref('Dupont');
</script>
<template>
<h2>Formulaire utilisateur</h2>
<!-- Plusieurs v-model sur le même composant -->
<FormUtilisateur
v-model:prenom="prenom"
v-model:nom="nom"
/>
<p>
Nom complet : {{ prenom }} {{ nom }}
</p>
</template>
Validation avancée des événements avec defineEmits()
En plus de la forme simple basée sur un tableau, il est possible de déclarer les événements sous forme d'objet. Chaque événement possède alors une fonction de validation qui contrôle la valeur transmise par l'enfant au moment de l'émission.
Cette vérification ne bloque pas l'exécution du code. Vue affiche un avertissement en mode développement lorsque la valeur ne respecte pas la règle définie. L'événement est tout de même émis, ce mécanisme sert surtout de garde-fou pendant le développement.
Forme de déclaration avec validation
On fournit un objet où chaque entrée associe un nom d'événement à une fonction de validation.
- chaque clé correspond à un nom d'événement
- chaque valeur est une fonction qui doit retourner true ou false
<script setup>
// Validation avancée de l'événement "update:modelValue".
const emit = defineEmits({
'update:modelValue': (valeur) =>
{
// Par exemple, exiger au moins 3 caractères utiles.
return typeof valeur === 'string' && valeur.trim().length >= 3;
}
});
</script>
La fonction associée à chaque événement reçoit les mêmes arguments que ceux passés à emit(). Elle s'exécute à chaque émission en mode développement et doit retourner true lorsque les valeurs sont acceptables. Un retour false déclenche un avertissement dans la console. L'événement est toutefois transmis au parent.
Exemple complet avec un champ lié par v-model
Dans cet exemple, le composant enfant expose un champ de texte utilisé avec v-model côté parent. L'enfant déclare la prop modelValue et l'événement update:modelValue avec validation. Si le message contient moins de trois caractères utiles, Vue affiche un avertissement en développement au moment de l'émission.
Le parent, lui, se contente d'utiliser v-model sur le composant. Il profite du couplage automatique modelValue / update:modelValue sans configuration supplémentaire.
Composant enfant ChampMessageValide.vue (src/components/ChampMessageValide.vue)
<script setup>
// Prop utilisée par v-model côté parent.
const props = defineProps({
modelValue: { type: String, default: '' }
});
// Déclare l'événement "update:modelValue" avec validation.
const emit = defineEmits({
'update:modelValue': (valeur) =>
{
// Le message doit être une chaîne avec au moins 3 caractères utiles.
return typeof valeur === 'string' && valeur.trim().length >= 3;
}
});
// Fonction appelée à chaque saisie dans le champ.
// Remonte la nouvelle valeur au parent via l'événement "update:modelValue".
const mettreAJourValeur = (event) =>
{
emit('update:modelValue', event.target.value);
};
</script>
<template>
<div>
<input
:value="props.modelValue"
@input="mettreAJourValeur"
type="text"
placeholder="Tape un message d'au moins 3 caractères"
/>
<p>
Longueur du message :
{{ props.modelValue.trim().length }} caractère(s) utile(s)
</p>
</div>
</template>
Composant parent App.vue (src/App.vue)
<script setup>
import { ref } from 'vue';
import ChampMessageValide from './components/ChampMessageValide.vue';
// Message réactif dans le parent, lié avec v-model.
const messageParent = ref('');
</script>
<template>
<section>
<h2>Validation d'un événement avec defineEmits</h2>
<!--
v-model relie "messageParent" à la prop "modelValue"
et à l'événement "update:modelValue" de l'enfant.
Si la validation de defineEmits échoue, Vue affiche un
avertissement en développement dans la console.
-->
<ChampMessageValide v-model="messageParent" />
<h3>Valeur reçue dans le parent</h3>
<p>Message actuel : {{ messageParent }}</p>
</section>
</template>
Dans cet exemple, le flux reste simple. L'enfant valide la valeur avant de l'émettre, signale les cas problématiques via la console, puis transmet malgré tout l'information au parent. Ce dernier reste libre de filtrer à son tour ou non les valeurs reçues.
Insérer du contenu dans un composant enfant avec les slots
Jusqu'ici, un composant enfant contrôlait entièrement son contenu et décidait tout seul de ce qu'il affichait. Le parent lui envoyait des données via les props et c'était ensuite le template de l'enfant qui choisissait le HTML final.
Les slots abordent le problème sous un autre angle. Le composant enfant garde sa structure générale (cadre, bordure, icône, disposition) et réserve une zone où le parent peut injecter son propre contenu. Les slots servent donc à laisser passer du contenu HTML plutôt qu'une simple valeur.
On peut résumer la différence ainsi.
- Props : Le parent envoie des valeurs que l'enfant utilise dans son propre template. L'enfant décide de la manière dont ces valeurs sont affichées.
- Slots : L'enfant fournit le cadre et des emplacements vides, le parent choisit directement le HTML inséré dans ces zones.
Techniquement, un slot est un emplacement réservé dans le template du composant enfant où le parent peut insérer son propre contenu HTML.
Contrairement à un composant auto-fermant (ex. <MonComposant />), un composant utilisant des slots s'écrit avec une balise ouvrante et une balise fermante (ex. <MonComposant></MonComposant>). Le contenu placé entre ces deux balises remplace le slot défini dans le composant enfant.
Exemple
Composant enfant AlertBox.vue (src/components/AlertBox.vue) :
<template>
<div class="alert">
<!--
Contenu communiqué par le parent.
Si le parent n'ajoute rien entre les balises <AlertBox></AlertBox> de son propre <template>,
alors rien ne s'affichera ici.
-->
<slot></slot>
</div>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import AlertBox from './components/AlertBox.vue';
</script>
<template>
<AlertBox>
<b>Attention :</b> Vous devez enregistrer avant de quitter.
</AlertBox>
</template>
Résultat affiché dans le navigateur :
<div class="alert">
<b>Attention :</b> Vous devez enregistrer avant de quitter.
</div>
Le slot par défaut
Un slot par défaut permet au composant enfant d'afficher un contenu automatique lorsque le parent ne lui en fournit aucun. Si le parent place du contenu entre les balises du composant, ce contenu remplacera celui prévu par défaut dans le slot.
Exemple
Composant enfant AlertBox.vue (src/components/AlertBox.vue) :
<!-- src/components/AlertBox.vue -->
<template>
<div class="alert">
<!--
Emplacement personnalisable dans le composant.
Si le parent fournit du contenu entre <AlertBox> ... </AlertBox>,
ce contenu remplacera celui indiqué ici.
À défaut, le texte "Message par défaut" sera affiché.
-->
<slot>Message par défaut</slot>
</div>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import AlertBox from './components/AlertBox.vue';
</script>
<template>
<!--
Ici, aucun contenu n'est inséré entre les balises <AlertBox>.
Le composant enfant utilisera donc le texte par défaut du slot.
-->
<AlertBox></AlertBox>
</template>
Résultat affiché dans le navigateur :
<div class="alert">
Message par défaut
</div>
Slots nommés
Certains composants ont plusieurs sections différentes à personnaliser (ex : une carte avec un titre et un contenu). On peut alors définir plusieurs slots avec des noms distincts.
Configurer un slot nommé :
- Composant parent :
Pour un slot nommé, le contenu est fourni dans le template du parent
à l'intérieur d'une balise
<template>
avec l'attribut #nomDuSlot
(ou sa forme longue v-slot:nomDuSlot).
<template> <composantEnfant> <template #nomDuSlot> Contenu HTML que l'on souhaite transmettre au slot "nomDuSlot" du composant enfant. </template> </composantEnfant> </template> - Composant enfant :
En parallèle, le composant enfant doit déclarer un slot portant exactement le même nom
via <slot name="nomDuSlot"> dans son propre
<template>.
<template> <slot name="nomDuSlot">Contenu par défaut</slot> </template>
Il existe également une syntaxe appliquée directement sur la balise du composant, mais elle est utilisée dans des cas plus avancés comme les slots scopés. Nous y reviendrons plus tard dans ce cours.
Exemple
Composant enfant Carte.vue (src/components/Carte.vue) :
<template>
<article class="carte">
<header>
<!--
Slot nommé "titre".
Le parent peut remplacer ce contenu en fournissant
une balise <template #titre> dans son propre template.
Si rien n'est fourni pour ce slot nommé,
le texte "Titre par défaut" sera affiché.
-->
<slot name="titre">Titre par défaut</slot>
</header>
<div class="contenu">
<!--
Slot par défaut.
Le parent peut fournir du contenu directement entre les balises <Carte> ... </Carte>.
Si aucun contenu n'est donné, le texte "Contenu par défaut" sera affiché.
-->
<slot>Contenu par défaut</slot>
</div>
</article>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import Carte from './components/Carte.vue';
</script>
<template>
<Carte>
<!--
Le contenu de ce <template>
remplace le slot nommé "titre" dans le composant enfant.
Syntaxe abréviée de v-slot:titre → #titre
-->
<template #titre>
<h3>Promotion du printemps</h3>
</template>
<!--
Tout contenu placé à l'intérieur de <Carte> mais en dehors
des balises <template> de slots nommés est injecté dans
le slot par défaut défini avec <slot> (sans nom) dans
le composant enfant. Ce texte remplace donc le contenu prévu
dans le slot par défaut de Carte.vue.
-->
Profitez de -30% sur toute la boutique.
</Carte>
</template>
Résultat affiché dans le navigateur :
<article class="carte">
<header>
<h3>Promotion du printemps</h3>
</header>
<div class="contenu">
Profitez de -30% sur toute la boutique.
</div>
</article>
Récupérer des données du composant enfant avec les slots scopés
Un slot scopé est un slot qui transmet des données du composant enfant vers son parent pour une zone précise de l'affichage. L'enfant garde la main sur la logique et les données, le parent choisit le code HTML qui sera rendu à cet endroit.
Attention à ne pas confondre le rôle des slots scopés et celui des emits. Un emit sert à signaler qu'une action s'est produite et à demander au parent de mettre à jour son état (suppression, ajout, bascule de statut, etc.). Un slot scopé intervient plutôt quand l'enfant possède des informations que le parent doit pouvoir réutiliser pour construire un gabarit personnalisé, sans dupliquer la logique du composant.
On privilégie un slot scopé plutôt qu'un emit dans les situations suivantes.
- Le composant enfant gère une logique ou des données internes et le parent veut simplement choisir la manière d'afficher ces données.
- Il n'y a pas d'action à déclencher dans le parent mais un besoin d'accéder à des valeurs pour les présenter différemment selon le contexte (affichage compact, détaillé, version mobile, etc.).
- On veut rendre un composant très réutilisable en laissant la structure générale et la logique dans l'enfant tout en permettant au parent de personnaliser certaines zones d'affichage.
Dès que le parent doit réagir à une action utilisateur (clic, soumission, changement de valeur), emit ou v-model reste la bonne solution. Le slot scopé complète ces mécanismes, il ne les remplace pas.
Vue applique une logique simple pour les slots scopés.
-
Côté enfant
Une donnée est exposée en tant que propriété du slot dans le
<template> du composant enfant
(exemple :
<slot :cle="valeur">).
- cle nom choisi par l'enfant pour rendre la donnée accessible au parent. Ce nom sera utilisé dans le template du parent.
- valeur donnée interne à l'enfant (variable déclarée dans <script setup>).
-
Côté parent
La donnée transmise par l'enfant est récupérée via une destructuration
dans une balise <template>
(exemple :
<template #default="{ cle }">).
- Le nom cle doit correspondre exactement à celui utilisé par l'enfant dans <slot :cle="valeur">.
- L'attribut #default indique que ce contenu remplace le slot par défaut du composant enfant. On l'utilise ici car l'enfant n'a pas défini de slot nommé spécifique.
Ainsi, le composant enfant fournit l'information et le parent choisit l'affichage dans la zone du slot. Aucun HTML ne circule en tant que chaîne de caractères, seuls des objets JavaScript passent du composant enfant vers le parent et sont ensuite utilisés dans le template du parent.
Exemple : personnaliser l'affichage d'un prix
Dans cet exemple, le composant enfant connaît un prix hors taxe. Le parent décide de la manière dont ce prix doit être affiché à l'écran prix hors taxe ou prix toutes taxes comprises. Un slot scopé permet au parent de récupérer cette donnée sans modifier le composant enfant.
Composant enfant Produit.vue (src/components/Produit.vue) :
<script setup>
const nom = 'Pizza Margherita'
const prixHt = 11.5
</script>
<template>
<div class="produit">
<h3>{{ nom }}</h3>
<!--
Slot scopé.
Le composant enfant expose sa donnée "prixHt"
afin que le parent puisse décider de l'affichage final.
Si le parent ne fournit rien pour ce slot,
le texte "Prix HT : {{ prixHt }} €" sera utilisé.
-->
<slot :prixHt="prixHt">
Prix HT : {{ prixHt }}€
</slot>
</div>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import Produit from './components/Produit.vue'
</script>
<template>
<Produit>
<!--
On récupère "prixHt" transmis par l'enfant.
On personnalise l'affichage pour montrer un prix TTC.
-->
<template #default="{ prixHt }">
<p>
Prix TTC :
<b>{{ (prixHt * 1.21).toFixed(2) }} €</b>
</p>
</template>
</Produit>
</template>
Ici, un emit ne conviendrait pas. Le parent n'a pas une action à exécuter, il doit simplement choisir comment afficher une donnée qui appartient au composant enfant. Le slot scopé donne accès à cette donnée tout en gardant la logique dans Produit.vue.
Exemple avancé : slot nommé et slot par défaut avec données scopées
Dans cet exemple, le composant enfant expose deux informations : le nom du produit et le prix HT. Le parent personnalise l'affichage du titre dans un slot nommé et transforme le prix HT en prix TTC dans le slot par défaut. Le composant enfant ne connaît ni la mise en forme finale, ni le calcul TTC.
Composant enfant Produit.vue (src/components/Produit.vue) :
<script setup>
const nom = 'Pizza Margherita'
const prixHt = 11.5
</script>
<template>
<div class="produit">
<!--
Slot nommé "titre" qui transmet aussi le nom du produit.
Le parent peut remplacer complètement le contenu du titre.
-->
<slot name="titre" :nom="nom">
<h3>{{ nom }}</h3>
</slot>
<!--
Slot par défaut qui transmet le prix hors taxe.
Le parent pourra interpréter cette information comme il le souhaite.
-->
<slot :prixHt="prixHt">
Prix HT : {{ prixHt }} €
</slot>
</div>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import Produit from './components/Produit.vue'
</script>
<template>
<Produit>
<!--
Slot nommé.
On récupère "nom" transmis par l'enfant
et on personnalise complètement l'affichage du titre.
-->
<template #titre="{ nom }">
<h2>Découvrez : {{ nom }}</h2>
</template>
<!--
Slot par défaut.
On récupère "prixHt" transmis par l'enfant
et on affiche un prix TTC formaté.
-->
<template #default="{ prixHt }">
<p>
Prix TTC :
<b>{{ (prixHt * 1.21).toFixed(2) }} €</b>
</p>
</template>
</Produit>
</template>
Dans ces exemples, le slot scopé rend les données du composant enfant disponibles dans le template du parent sans créer de nouvelle logique de calcul côté parent. Un emit serait plus adapté si l'utilisateur cliquait sur un bouton pour ajouter la pizza au panier ou changer la quantité. Pour le simple choix de présentation d'une donnée déjà connue par l'enfant, le slot scopé reste plus adapté.
Éviter de manipuler le DOM global dans un composant
En développement avec Vue, on évite d'utiliser document.querySelector() à l'intérieur d'un composant. Cette fonction parcourt toute la page et casse l'idée qu'un composant gère uniquement son propre morceau de DOM. Dès que plusieurs instances cohabitent, le comportement devient difficile à prévoir.
Un composant doit gérer son propre DOM, pas celui du document entier.
Exemple de ce qu'il ne faut pas faire
L'exemple suivant illustre une mauvaise pratique. Le composant tente de modifier l'apparence de son propre bouton lors d'un clic en utilisant document.querySelector(). Or cette fonction sélectionne le premier bouton de tout le document. Si plusieurs instances du composant sont affichées, seul le premier bouton sera modifié, ce qui n'est pas le comportement attendu.
Composant enfant BoutonCouleur.vue (src/components/BoutonCouleur.vue) :
<script setup>
import { ref } from 'vue';
const changerCouleur = () =>
{
// Mauvaise pratique : Bien que le template du composant ne contient
// qu'une seule balise "button", la sélection ciblera toujours
// le premier bouton de la page et non celui du composant.
const bouton = document.querySelector('button');
bouton.style.backgroundColor = 'orange';
};
</script>
<template>
<button @click="changerCouleur">
Changer ma couleur
</button>
</template>
Composant parent App.vue (src/App.vue) :
<script setup>
import BoutonCouleur from './components/BoutonCouleur.vue'
</script>
<template>
<BoutonCouleur />
<BoutonCouleur />
</template>
Ce comportement est problématique car les composants ne sont plus indépendants. Une action dans un composant peut affecter un autre, ce qui contredit totalement le principe d'encapsulation de Vue.
Utiliser les fonctionnalités de Vue pour manipuler uniquement le DOM du composant
Vue propose un mécanisme sécurisé pour accéder à un élément spécifique du template d'un composant : les références de template. Contrairement à document.querySelector(), elles ne ciblent que l'élément auquel elles sont directement attachées, dans la portée locale du composant.
Pour utiliser une référence de template, il suffit de suivre deux étapes : une dans le script, une dans le template, puis Vue s'occupe du reste.
- Côté script : on déclare une donnée réactive avec ref(null) pour accueillir plus tard l'élément du DOM (ex. : const nomDeLaRef = ref(null)).
- Côté template : on ajoute l'attribut ref="nomDeLaRef" sur l'élément HTML que l'on souhaite manipuler.
Vue relie les deux automatiquement : une fois le composant affiché à l'écran, bouton.value contient l'élément HTML du composant. On peut alors l'utiliser en toute sécurité sans toucher au DOM global.
Correction de l'exemple précédent avec une référence de template
Reprenons le composant de l'exemple précédent qui posait problème avec document.querySelector(). Pour que chaque instance agisse uniquement sur son propre bouton, nous allons utiliser une référence de template.
- Dans le template du composant, on ajoute l'attribut ref="nomRef" sur l'élément à cibler.
- Dans le bloc <script setup>, on crée une ref JavaScript du même nom (ex. : const nomRef = ref(null)).
- Vue remplit automatiquement nomRef.value avec l'élément HTML correspondant lorsque le composant est monté.
On peut alors manipuler cet élément sans jamais toucher au DOM global, même si plusieurs instances du composant sont présentes.
Version correcte du composant enfant BoutonCouleur.vue (src/components/BoutonCouleur.vue) :
<script setup>
import { ref } from 'vue';
// "bouton" référence l'élément associé au ref="bouton" dans le template.
// Vue la remplit automatiquement lors du montage du composant.
const bouton = ref(null);
const changerCouleur = () =>
{
// "bouton.value" pointe ici vers l'élément HTML de CE composant uniquement.
bouton.value.style.backgroundColor = 'orange';
};
</script>
<template>
<!--
Le ref lie cet élément à la ref JavaScript "bouton".
Chaque instance du composant aura donc sa propre référence.
-->
<button ref="bouton" @click="changerCouleur">
Changer ma couleur
</button>
</template>
Avec plusieurs instances du composant BoutonCouleur.vue, chaque bouton reste désormais totalement indépendant du reste du document.
Exercices : Découper la todolist en composants
Jusqu'à présent, toute votre todolist était gérée dans un seul composant App.vue (affichage, formulaire, actions, tri, persistance dans localStorage). Vous allez maintenant découper ce code en plusieurs composants enfants plus faciles à lire, organiser et réutiliser.
Chaque composant aura un rôle clair. Les données resteront dans App.vue et les composants enfants recevront les informations dont ils ont besoin, puis enverront des événements pour que le parent réagisse.
L'état principal de la todolist reste géré dans App.vue (tableau des tâches, prochain identifiant, mode de tri, synchronisation avec localStorage). Les composants enfants ne stockent aucune copie de la liste. Ils reçoivent les données via des props et notifient le parent grâce aux emits.
Attention : Avant de commencer, faites une sauvegarde de votre projet (via Git ou une copie du dossier). Vous allez déplacer du code en plusieurs morceaux et il est fréquent de casser l'application à cette étape.
Avant de commencer les exercices, voici l'organisation recommandée de votre projet. Cette arborescence vous servira de guide pour savoir où créer chaque composant et comment structurer la todolist après le découpage.
📁 src
├── 📄 App.vue Garde l'état principal (tâches, tri, id, localStorage)
├── 📄 main.js
└── 📁 components
├── 📄 TodoForm.vue Formulaire d'ajout
├── 📄 TodoList.vue Liste complète
└── 📄 TodoItem.vue Tâche individuelle
Exercice 1 : Composant TodoItem pour une tâche
Objectif : Extraire l'affichage d'une tâche individuelle dans un composant TodoItem.vue afin de clarifier le code et de préparer la communication par événements (emits).
Instructions : Partez de votre todolist complète dans App.vue (avec ajout, suppression, case à cocher, tri, persistance dans localStorage).
-
Créez un nouveau fichier
src/components/TodoItem.vue.
Ajoutez-y une structure de base :<script setup> // À compléter </script> <template> <li> <!-- Contenu d'une tâche à adapter depuis App.vue --> </li> </template> -
Dans App.vue, repérez dans le
<template> le code qui affiche
une tâche dans la liste
(le contenu d'un <li> à l'intérieur du
v-for : case à cocher, texte, bouton supprimer, etc.).
Copiez ce contenu dans le <li> de TodoItem.vue puis laissez-le aussi temporairement dans App.vue pour pouvoir revenir en arrière si nécessaire. -
Dans le bloc <script setup> de TodoItem.vue,
listez les données utilisées dans le bloc copié
(ex.: tache.id,
tache.texte,
tache.terminee, etc.).
Ce sont ces informations que le parent devra fournir à l'enfant via des props :
<script setup> const props = defineProps({ id: { type: Number, required: true }, texte: { type: String, required: true }, terminee: { type: Boolean, required: true } // Ajoutez ici d'autres props si votre tâche contient plus de champs }); </script> - Dans le <template> de TodoItem.vue, remplacez chaque utilisation de tache.quelqueChose par la prop correspondante reçue par le composant (ex.: Remplacer {{ tache.texte }} (propriété texte de l'objet tache) par {{ props.texte }} (nom de la prop déclarée dans la partie script)).
-
Dans le bloc <script setup> de App.vue, importez le composant enfant :
<script setup> // ... vos imports existants ... import TodoItem from './components/TodoItem.vue'; // ... état, fonctions, etc. ... </script> -
Dans le bloc <template> de App.vue,
remplacez le contenu de la boucle qui affichait directement les tâches
par une utilisation de <TodoItem>.
C'est ici que App.vue passe les données de chaque tâche (id, texte et terminee) aux props déclarées par le composant enfant :
Adaptez les noms de propriétés (tachesTriees, tache.texte, etc.) à votre propre code.<ul> <TodoItem v-for="tache in tachesTriees" :key="tache.id" :id="tache.id" :texte="tache.texte" :terminee="tache.terminee" /> </ul> - Vérifiez que la liste s'affiche toujours correctement (même si les boutons ne fonctionnent pas encore).
-
Dans le bloc <script setup> de TodoItem.vue,
déclarez les événements que le composant pourra émettre vers son parent.
Par exemple pour demander une suppression ou un changement de statut :<script setup> // ... vos props ... const emit = defineEmits([ 'demanderSuppression', 'demanderChangementStatut' // Ajoutez ici d'autres événements si nécessaire ]); </script> -
Dans le bloc <script setup> de
TodoItem.vue,
créez de petites fonctions intermédiaires.
Elles auront pour rôle d'appeler emit()
avec l'identifiant de la tâche.
Il est préférable d'utiliser des fonctions intermédiaires plutôt que d'appeler
emit() directement dans le template car
cela rend le code plus lisible, sépare mieux l'affichage et la logique,
et facilitera les évolutions du composant par la suite.
Créez par exemple :
- une fonction qui déclenche demanderSuppression()
- une fonction qui déclenche demanderChangementStatut()
-
Dans le <template> de
TodoItem.vue,
utilisez les fonctions intermédiaires créées dans le script.
Remplacez les anciens appels directs comme supprimerTache(id) ou basculerTerminee(id) par vos fonctions locales.- au clic sur le bouton supprimer : appeler votre fonction demanderSuppression()
- au changement de la case à cocher : appeler votre fonction demanderChangementStatut()
-
Dans le template de App.vue,
ajoutez l'écoute des événements sur
<TodoItem>,
en les reliant à vos fonctions existantes
(ex.: supprimerTache
et basculerStatutTache) :
Adaptez les noms des fonctions à votre propre code. Vérifiez que cocher une tâche et la supprimer fonctionnent encore comme avant le découpage.<ul> <TodoItem v-for="tache in tachesTriees" :key="tache.id" :id="tache.id" :texte="tache.texte" :terminee="tache.terminee" @demanderSuppression="supprimerTache" @demanderChangementStatut="basculerStatutTache" /> </ul> - Déplacez, si vous le souhaitez, le style spécifique à une tâche (ex.: une classe appliquée au <li> ou au texte de la tâche) dans un bloc <style scoped> de TodoItem.vue. L'objectif est que l'apparence d'une tâche soit définie dans le composant qui l'affiche, sans impacter le reste de la page. Vérifiez que le style visuel de chaque tâche reste identique à la version d'origine.
-
Nettoyez ensuite App.vue :
- supprimez le code qui dupliquait l'affichage d'une tâche dans le template
- Vérifiez que les fonctions de suppression et de changement de statut des tâches ne sont plus appelées directement depuis le template. Elles doivent désormais être déclenchées uniquement en réponse aux événements émis par TodoItem.
Exercice 2 : Composant TodoList pour la liste complète
Objectif : Déplacer l'affichage de la liste dans un composant TodoList.vue. Ce composant recevra la liste triée en props et relayera les événements émis par TodoItem vers le composant parent App.vue.
Instructions : Vous devez avoir terminé l'Exercice 1. Votre composant TodoItem.vue est fonctionnel et App.vue affiche déjà les tâches en l'utilisant.
-
Créez un fichier
src/components/TodoList.vue.
Ajoutez-y une structure de base :
<script setup> // À compléter </script> <template> <ul> <!-- Liste des TodoItem à afficher --> </ul> </template> - Dans App.vue, repérez dans le <template> la partie qui affiche la liste complète des tâches (la boucle v-for qui génère les <TodoItem>). Copiez ce bloc dans le <template> de TodoList.vue, mais laissez-le aussi temporairement dans App.vue pour pouvoir revenir en arrière si besoin.
-
Dans le bloc <script setup> de
TodoList.vue,
déclarez une prop pour recevoir la liste de tâches à afficher.
Le composant ne détient pas l'état, il reçoit simplement les données :
Dans le template de TodoList.vue, utilisez ensuite props.taches (ou une destructuration si vous préférez) pour la boucle.<script setup> const props = defineProps({ taches: { type: Array, required: true } // Ajoutez d'autres props si nécessaire (ex.: un mode de tri) }); </script> -
Adaptez maintenant le <template> de
TodoList.vue pour utiliser
TodoItem à l'intérieur d'une liste.
Le composant TodoList garde la boucle
v-for et transmet chaque tâche à l'enfant
via ses props :
Adaptez les noms de propriétés (taches, tache.texte, etc.) à votre propre structure.<template> <ul> <TodoItem v-for="tache in taches" :key="tache.id" :id="tache.id" :texte="tache.texte" :terminee="tache.terminee" /> </ul> </template> -
Dans le bloc <script setup> de
TodoList.vue,
déclarez les événements que le composant pourra émettre vers
App.vue.
Ce seront les mêmes événements que ceux émis par
TodoItem, mais relayés par la liste :
<script setup> const props = defineProps({ taches: { type: Array, required: true } }); const emit = defineEmits([ 'demanderSuppression', 'demanderChangementStatut' // Ajoutez ici d'autres événements si nécessaire ]); </script> -
Dans le <template> de
TodoList.vue,
écoutez les événements émis par
TodoItem et
réémettez-les immédiatement vers le parent.
C'est ici que l'identifiant de la tâche concernée remonte jusqu'à
App.vue :
Le composant TodoList se comporte ainsi comme un relais : il affiche la liste et remonte les événements, sans modifier lui-même le tableau des tâches.<template> <ul> <TodoItem v-for="tache in taches" :key="tache.id" :id="tache.id" :texte="tache.texte" :terminee="tache.terminee" @demanderSuppression="emit('demanderSuppression', tache.id)" @demanderChangementStatut="emit('demanderChangementStatut', tache.id)" /> </ul> </template> -
Dans App.vue,
importez le composant TodoList
dans le bloc <script setup> :
<script setup> // ... vos imports existants ... import TodoList from './components/TodoList.vue'; // ... état, fonctions, etc. ... </script> -
Remplacez ensuite, dans le
<template> de
App.vue, l'ancienne boucle qui
affichait directement les <TodoItem>
par une unique balise <TodoList>.
C'est ici que le parent fournit la liste triée et écoute les événements :
Adaptez les noms tachesTriees, supprimerTache, basculerStatutTache à votre code existant.<template> <!-- ... autres éléments de l'interface ... --> <TodoList :taches="tachesTriees" @demanderSuppression="supprimerTache" @demanderChangementStatut="basculerStatutTache" /> <!-- ... autres éléments de l'interface ... --> </template> -
Vérifiez que tout fonctionne comme avant le découpage :
- la liste s'affiche correctement
- cocher une tâche met bien à jour son statut
- supprimer une tâche la retire bien de la liste
- le tri continue à s'appliquer comme prévu
-
Nettoyez ensuite App.vue :
- supprimez l'ancienne boucle v-for qui affichait les <TodoItem> directement dans le template
- vérifiez que les fonctions supprimerTache et basculerStatutTache ne sont plus appelées ailleurs que via les événements venant de TodoList
Exercice 3 : Composant TodoForm pour le formulaire d'ajout
Objectif Déplacer le formulaire d'ajout de tâche dans un composant séparé TodoForm.vue. Ce composant gèrera le champ de saisie et l'événement de soumission, tandis que App.vue gardera l'état de la liste et la logique d'ajout.
Instructions : Vous devez avoir terminé les exercices précédents : TodoItem.vue pour une tâche et TodoList.vue pour la liste.
-
Créez un nouveau fichier
src/components/TodoForm.vue.
Ajoutez-y une structure de base :<script setup> // À compléter </script> <template> <form> <!-- Champ de saisie et bouton d'ajout à adapter depuis App.vue --> </form> </template> -
Dans App.vue, repérez dans le
<template> le formulaire d'ajout de tâche
(champ texte, bouton, éventuel @submit.prevent ou
@click qui appelle la fonction d'ajout).
Copiez ce bloc dans le <form> de TodoForm.vue, mais laissez-le aussi temporairement dans App.vue pour pouvoir revenir facilement en arrière. -
Dans le bloc <script setup> de
TodoForm.vue, créez une donnée réactive
pour le texte de la nouvelle tâche
(ex.: nouvelleTache) et liez-la au champ de saisie
avec v-model.
Adaptez les noms aux vôtres si vous utilisez déjà un autre identifiant.<script setup> import { ref } from 'vue'; const nouvelleTache = ref(''); </script> <template> <form> <input type="text" v-model="nouvelleTache" > <button type="submit">Ajouter</button> </form> </template> -
Toujours dans TodoForm.vue,
déclarez l'événement que le composant pourra émettre vers le parent
lorsque l'utilisateur soumet le formulaire.
L'idée est de demander à App.vue
d'ajouter une nouvelle tâche avec le texte saisi.
<script setup> import { ref } from 'vue'; const nouvelleTache = ref(''); const emit = defineEmits([ 'demanderAjoutTache' // Ajoutez d'autres événements si nécessaire ]); </script> -
Dans le bloc <script setup> de
TodoForm.vue,
créez une fonction intermédiaire pour gérer la soumission du formulaire.
Elle devra :
- empêcher l'ajout si le champ est vide ou uniquement composé d'espaces
- émettre l'événement demanderAjoutTache en transmettant le texte nettoyé
- réinitialiser le champ après un ajout réussi
Cette fonction regroupe la logique liée au formulaire et limite le template à l'affichage.<script setup> import { ref } from 'vue'; const nouvelleTache = ref(''); const emit = defineEmits(['demanderAjoutTache']); const gererSoumission = () => { const texteNettoye = nouvelleTache.value.trim(); if (texteNettoye === '') { return; } emit('demanderAjoutTache', texteNettoye); nouvelleTache.value = ''; }; </script> -
Dans le <template> de
TodoForm.vue,
utilisez cette fonction pour gérer l'événement de soumission du formulaire.
Par exemple :
Adaptez le texte, les classes CSS et les attributs à votre interface.<template> <form @submit.prevent="gererSoumission"> <input type="text" v-model="nouvelleTache" placeholder="Ajouter une tâche" > <button type="submit"> Ajouter </button> </form> </template> -
Dans App.vue,
importez le composant TodoForm
dans le bloc <script setup> :
<script setup> // ... vos imports existants ... import TodoForm from './components/TodoForm.vue'; // ... état, fonctions, etc. ... </script> -
Remplacez ensuite, dans le
<template> de
App.vue,
l'ancien formulaire d'ajout par une balise
<TodoForm>.
C'est ici que le parent écoute l'événement d'ajout et appelle sa fonction
qui insère la tâche dans le tableau (avec l'id, la date, etc.) :
Adaptez le nom de la fonction ajouterTache à votre code existant. Cette fonction doit créer un objet tâche complet à partir du texte reçu (id, texte, statut, etc.) et l'ajouter au tableau géré par App.vue.<template> <!-- ... autres éléments de l'interface ... --> <TodoForm @demanderAjoutTache="ajouterTache" /> <TodoList :taches="tachesTriees" @demanderSuppression="supprimerTache" @demanderChangementStatut="basculerStatutTache" /> <!-- ... autres éléments de l'interface ... --> </template> -
Vérifiez que le comportement reste identique à la version précédente :
- taper une tâche puis valider l'ajout l'insère bien dans la liste
- le champ se vide après un ajout réussi
- les tâches continuent à être sauvegardées dans localStorage
- le tri et les interactions de la liste fonctionnent toujours
-
Nettoyez enfin App.vue :
- supprimez le formulaire d'ajout qui était encore présent dans le template si vous l'aviez conservé pour comparaison
- supprimez la donnée réactive liée au champ de saisie (ex.: nouvelleTache) si elle n'est plus utilisée dans App.vue
- vérifiez que la logique d'ajout de tâche se trouve bien uniquement dans la fonction appelée par l'événement demanderAjoutTache et non dans plusieurs endroits dispersés