Cours Angular · Module 2: Composants

Input et Output - la communication entre composants

8 min de lecture
Dans cette leçon6

Un nouveau composant est apparu au dojo : la carte de guerrier app-samurai-card. Elle doit afficher le nom et le niveau d'un samouraï, mais comment saurait-elle lequel ? Elle ne peut pas le deviner seule, car la liste des élèves est connue du composant englobant, le parent. Le même souci existe dans l'autre sens : quand l'élève clique sur le bouton d'attaque d'une carte, la liste doit savoir qui a attaqué pour l'inscrire dans le journal de combat.

Angular règle cela avec une seule règle : les données descendent, les événements remontent. Le parent donne des ordres à l'enfant par @Input, et l'enfant envoie des rapports au parent par @Output. Comme dans l'armée du shogun : un général ne lit pas dans la tête d'un soldat, et un soldat ne donne pas d'ordres à un général. Les deux côtés communiquent par des canaux convenus.

@Input - les ordres du shogun

Le décorateur @Input() marque un champ dont le parent peut fournir la valeur. Le champ reçoit une valeur par défaut, que le composant utilise quand le parent ne transmet rien :

1import { Component, Input } from '@angular/core';
2
3@Component({
4  selector: 'app-samurai-card',
5  standalone: true,
6  template: `
7    <div class="card">
8      <h3>{{ name }}</h3>
9      <p>Niveau : {{ level }}</p>
10    </div>
11  `
12})
13export class SamuraiCardComponent {
14  @Input() name = '';
15  @Input() level = 1;
16}

Tu importes le décorateur Input depuis @angular/core, comme Component. La carte ne sait pas d'où viendront les données, et elle n'a pas besoin de le savoir : elle affiche simplement ce qu'elle reçoit. Le parent transmet les valeurs sous forme d'attributs dans la balise du composant :

1<app-samurai-card name="Musashi" [level]="10"></app-samurai-card>
2<app-samurai-card [name]="bestStudent" [level]="bestLevel"></app-samurai-card>

name="Musashi" transmet un texte fixe. [level]="10" est la liaison de propriété de la deuxième leçon : Angular évalue l'expression 10 et transmet un nombre. Sans les crochets, sous la forme level="10", l'enfant recevrait la chaîne « 10 ». Dans la deuxième ligne, [name]="bestStudent" lit le champ bestStudent de la classe du parent, et quand le parent le modifie, la carte affiche le nouveau nom toute seule. Pour du texte, les deux notations sont équivalentes : [name]="'Musashi'" donne le même résultat que name="Musashi". N'oublie pas non plus que le parent doit avoir SamuraiCardComponent dans sa liste imports.

Un input obligatoire

Parfois, une valeur par défaut n'a pas de sens. Une carte sans clan est inutile, nous voulons donc que le parent ne puisse pas l'oublier. C'est le rôle de l'option required du décorateur :

1export class SamuraiCardComponent {
2  @Input() name = '';
3  @Input() level = 1;
4  @Input({ required: true }) clan!: string;
5}

Si le parent place la carte sans l'attribut clan, le compilateur d'Angular signale une erreur avant même le démarrage de l'application. Le point d'exclamation dans clan!: string s'adresse à TypeScript : c'est une promesse que le champ recevra une valeur, même si sa déclaration ne lui en donne pas. Sans lui, le mode strict, qu'Angular active par défaut, signalerait que le champ n'a pas de valeur initiale. Ici, la promesse est sûre, car required garantit que le parent fournira la valeur.

@Output - le rapport au shogun

Passons à la direction opposée. L'enfant annonce un événement par un champ marqué du décorateur @Output(). La valeur du champ est un EventEmitter, un émetteur d'événements, et entre chevrons tu indiques le type de la valeur que porte l'événement :

1import { Component, Output, EventEmitter } from '@angular/core';
2
3@Component({
4  selector: 'app-attack-button',
5  standalone: true,
6  template: `<button (click)="onAttack()">Attaquer !</button>`
7})
8export class AttackButtonComponent {
9  @Output() attacked = new EventEmitter<number>();
10
11  onAttack(): void {
12    const damage = Math.floor(Math.random() * 100);
13    this.attacked.emit(damage);
14  }
15}

new EventEmitter<number>() crée un émetteur d'événements qui portent un nombre. <number> est un paramètre de type : TypeScript veille à ce que personne n'envoie une chaîne ou un objet par attacked. Lors d'un clic sur le bouton, la méthode onAttack() tire au hasard des dégâts de 0 à 99 et les envoie au parent par l'appel this.attacked.emit(damage). emit seul n'affiche rien. Ce n'est qu'un rapport, et c'est le parent qui décide quoi en faire.

Le parent écoute l'événement de l'enfant comme un clic, avec une liaison d'événement qui utilise le nom du champ marqué @Output :

1import { Component } from '@angular/core';
2import { AttackButtonComponent } from './attack-button.component';
3
4@Component({
5  selector: 'app-battle',
6  standalone: true,
7  imports: [AttackButtonComponent],
8  template: `
9    <app-attack-button (attacked)="handleAttack($event)"></app-attack-button>
10    <p>Derniers dégâts : {{ lastDamage }}</p>
11  `
12})
13export class BattleComponent {
14  lastDamage = 0;
15
16  handleAttack(damage: number): void {
17    this.lastDamage = damage;
18    console.log(`Dégâts infligés : ${damage}`);
19  }
20}

Avec un événement venant de @Output, la variable $event n'est pas un événement du navigateur, mais exactement la valeur que l'enfant a passée à emit(). Ici, ce sont les dégâts, donc handleAttack les enregistre aussitôt dans lastDamage, et l'interpolation les affiche à l'écran. L'enfant ne sait rien de lastDamage, et le parent ne sait pas comment l'enfant calcule les dégâts. Chacun veille sur sa propre partie.

Le flux complet, étape par étape

Suivons encore une fois le chemin du rapport, de l'enfant jusqu'au parent. Chaque étape découle de la précédente :

  1. L'enfant déclare le champ @Output() attacked = new EventEmitter<number>().
  2. Un événement se produit dans l'enfant, par exemple l'élève clique sur le bouton d'attaque.
  3. L'enfant appelle this.attacked.emit(damage) et envoie la valeur.
  4. Le parent la reçoit par (attacked)="handleAttack($event)", où $event est la valeur envoyée.

Un ordre qui descend par @Input est plus simple, car il n'a besoin d'aucun événement : le parent modifie la valeur dans son champ, et Angular la transmet à l'enfant au prochain rafraîchissement de la vue.

La nouvelle API : les fonctions input() et output()

Depuis Angular 19, les fonctions input() et output() sont également stables, et elles font le même travail sans décorateurs. Voici une carte de guerrier écrite dans ce style :

1import { Component, input, output } from '@angular/core';
2
3@Component({
4  selector: 'app-warrior-card',
5  standalone: true,
6  template: `
7    <h3>{{ name() }}</h3>
8    <button (click)="attacked.emit(name())">Attaquer !</button>
9  `
10})
11export class WarriorCardComponent {
12  name = input('Inconnu');
13  attacked = output<string>();
14}

L'appel input('Inconnu') crée un input avec une valeur par défaut. Tu lis sa valeur avec des parenthèses, name(), parce que c'est un signal, une valeur qui prévient elle-même Angular de ses changements. Tu étudieras les signaux en détail dans le sixième module. Un input obligatoire s'écrit input.required<string>(). De son côté, output<string>() remplace @Output() avec un EventEmitter et possède la même méthode emit(). Le parent ne voit aucune différence : il écrit toujours [name]="..." et (attacked)="...". Dans les exercices de ce module, tu peux utiliser l'une ou l'autre notation, mais il vaut la peine de connaître les décorateurs, car tu les rencontreras dans la plupart des projets existants.

Exemple complet - une liste avec sélection

Réunissons les deux directions. La carte de samouraï du premier exemple reçoit un bouton et un événement selected, et la liste affiche les cartes dans une boucle @for et retient le guerrier choisi :

1// La carte de samouraï du premier exemple, complétée par un @Output
2import { Component, Input, Output, EventEmitter } from '@angular/core';
3
4@Component({
5  selector: 'app-samurai-card',
6  standalone: true,
7  template: `
8    <div class="card">
9      <h3>{{ name }}</h3>
10      <p>Niveau : {{ level }}</p>
11      <button (click)="selected.emit(name)">Choisir</button>
12    </div>
13  `
14})
15export class SamuraiCardComponent {
16  @Input() name = '';
17  @Input() level = 1;
18  @Input({ required: true }) clan!: string;
19  @Output() selected = new EventEmitter<string>();
20}
21
22// Liste de samouraïs avec possibilité de sélection
23@Component({
24  selector: 'app-samurai-list',
25  standalone: true,
26  imports: [SamuraiCardComponent],
27  template: `
28    @for (samurai of samuraiList; track samurai.id) {
29      <app-samurai-card
30        [name]="samurai.name"
31        [level]="samurai.level"
32        [clan]="samurai.clan"
33        (selected)="onSelect($event)">
34      </app-samurai-card>
35    }
36
37    @if (selectedName) {
38      <p>Sélectionné : {{ selectedName }}</p>
39    }
40  `
41})
42export class SamuraiListComponent {
43  samuraiList = [
44    { id: 1, name: 'Musashi', level: 10, clan: 'Niten' },
45    { id: 2, name: 'Kojiro', level: 9, clan: 'Ganryū' }
46  ];
47
48  selectedName = '';
49
50  onSelect(name: string): void {
51    this.selectedName = name;
52  }
53}

La liste transmet trois inputs à chaque carte : name, level et le clan obligatoire. La carte affiche le nom et le niveau, et quand on clique sur son bouton, elle envoie son nom par l'appel selected.emit(name), directement depuis le template. La liste le reçoit par (selected)="onSelect($event)", l'enregistre dans selectedName, et le bloc @if affiche le paragraphe « Sélectionné : Musashi ». Tu as rencontré chaque élément de cet exemple dans ce module : le décorateur, les liaisons, le flux de contrôle et la communication entre composants.

Retiens bien : les ordres descendent par @Input, les rapports remontent par @Output et emit(), et aucun guerrier ne met la main dans la classe d'un autre sans canal convenu.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel décorateur est utilisé pour envoyer des événements d'un composant enfant vers son parent ?

  2. 2. Quel décorateur permet à un composant enfant de recevoir des données de son composant parent ?

  3. 3. Comment marque-t-on un @Input comme obligatoire, selon la leçon ?

  4. 4. Quelle syntaxe représente correctement la liaison bidirectionnelle dans Angular ?

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Ordonne les éléments pour créer un objet d'événement : new EventEmitter<number>()

  • Éditeur de code

    Complète le décorateur @Input pour le nom du guerrier et le décorateur @Output pour l'événement d'attaque.

  • Tri horizontal

    Ordonne les éléments sur une seule ligne pour émettre un événement avec les dégâts : this.attacked.emit(damage);

  • Clique dans l'ordre

    Ordonne les éléments pour transmettre le niveau du guerrier à l'enfant par liaison de propriété : [level]="10"

  • Tri vertical

    Ordonne les étapes du flux de données dans la communication parent-enfant via @Output (de haut en bas) :

  • Tri vertical

    Ordonne les principales parties d'un fichier de composant dans leur ordre habituel (de haut en bas) :

Articles utiles