Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Angular

Émettre des événements avec output() et EventEmitter dans Angular

Une sortie déclarée avec output() permet à l’enfant d’annoncer un événement avec emit(). Le parent l’écoute avec (nom)="…" et reçoit la valeur dans $event, typée et vérifiée à la compilation. Les donn...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Une sortie déclarée avec output() permet à l’enfant d’annoncer un événement avec emit(). Le parent l’écoute avec (nom)="…" et reçoit la valeur dans $event, typée et vérifiée à la compilation. Les données descendent par les entrées et les événements remontent par les sorties, mais d’un seul niveau : il faut les relayer. Le cours montre aussi comment bien nommer une sortie, s’y abonner par le code (subscribe, outputToObservable, outputFromObservable) et former le couple valeur / valeurChange pour [( )]. Il présente l’ancienne écriture @Output() + EventEmitter avec sa migration, puis se termine par 5 activités corrigées et les erreurs fréquentes.
  1. Objectifs

    • Déclarer une sortie avec output<T>() et émettre un événement avec emit()
    • Écouter une sortie dans le parent avec (nom)="…" et exploiter $event
    • Choisir un nom de sortie sans piège et utiliser un alias
    • Faire remonter un événement sur plusieurs niveaux en le relayant, tout en laissant les données au composant qui les possède
    • S'abonner à une sortie par le code et faire le lien avec RxJS (outputToObservable, outputFromObservable)
    • Créer le couple valeur / valeurChange qui rend possible [(valeur)]
    • Lire et migrer l'ancienne écriture @Output() + EventEmitter
  1. Présentation

    • Dans « Transmettre des données avec input() », les données descendaient du parent vers l'enfant. Il faut maintenant le chemin inverse : l'enfant doit pouvoir prévenir son parent qu'il s'est passé quelque chose (un clic, un choix, une validation).
    • C'est le rôle des sorties (outputs). L'enfant n'agit pas lui-même sur les données du parent : il annonce un événement, éventuellement accompagné d'une valeur, et le parent décide quoi en faire.
    • Depuis Angular 17.3, on déclare une sortie avec la fonction output(). L'ancienne écriture, @Output() avec un EventEmitter, reste très présente dans les projets existants : nous verrons les deux.

Une sortie transmet un événement de l'enfant vers le parent

  • Pour suivre ce chapitre, continuez dans le projet premiers-pas-angular et créez un dossier src/app/sorties/. Tous les exemples ont été testés avec Angular 22 (composants OnPush par défaut, sans Zone.js).
  1. Déclarer, émettre et écouter une sortie

    • Dans l'enfant, une sortie est une propriété initialisée avec output<T>(), importée depuis @angular/core. Le type T est celui de la valeur transportée ; output<void>() ne transporte rien.
    • Pour déclencher l'événement, l'enfant appelle emit(valeur), depuis son template ou depuis une méthode.
    • Dans le parent, on écoute la sortie exactement comme un événement du navigateur : (voter)="compter($event)". La variable $event contient la valeur passée à emit().
import { Component, output, signal } from '@angular/core';

type Vote = 'pour' | 'contre';

// Composant enfant : il annonce ce que l'utilisateur a fait, sans rien compter lui-même
@Component({
  selector: 'app-boutons-vote',
  template: `
    <button (click)="voter.emit('pour')">Pour</button>
    <button (click)="voter.emit('contre')">Contre</button>
    <button (click)="effacer.emit()">Effacer</button>
  `,
})
export class BoutonsVote {
  readonly voter = output<Vote>();     // la sortie transporte une valeur : 'pour' ou 'contre'
  readonly effacer = output<void>();   // la sortie ne transporte rien
}

// Composant parent : il écoute les sorties et décide quoi faire
@Component({
  selector: 'app-premiere-sortie',
  imports: [BoutonsVote],
  template: `
    <app-boutons-vote (voter)="compter($event)" (effacer)="remettreAZero()" />
    <p>Pour : {{ pour() }} · Contre : {{ contre() }} · dernier vote : {{ dernier() }}</p>
  `,
})
export class PremiereSortie {
  protected readonly pour = signal(0);
  protected readonly contre = signal(0);
  protected readonly dernier = signal('aucun');

  protected compter(vote: Vote): void {
    if (vote === 'pour') this.pour.update((n) => n + 1);
    else this.contre.update((n) => n + 1);
    this.dernier.set(vote);
  }

  protected remettreAZero(): void {
    this.pour.set(0);
    this.contre.set(0);
    this.dernier.set('aucun');
  }
}
Pour : 0 · Contre : 0 · dernier vote : aucun
(Pour, Pour, Contre)   Pour : 2 · Contre : 1 · dernier vote : contre
(Effacer)              Pour : 0 · Contre : 0 · dernier vote : aucun
  • BoutonsVote ne compte rien : il ne possède aucune donnée. Le même composant pourrait servir à un sondage, à des commentaires ou à un quiz ; c'est le parent qui donne un sens au vote.
  • $event est typé Vote: compter($event) est vérifié par le compilateur. Avec output<void>(), on écrit simplement emit() et le parent n'utilise pas $event.
  • Une sortie est déclarée readonly: on ne remplace jamais l'objet, on l'utilise seulement pour émettre.
  • Attention : output() ne peut être appelée que dans l'initialisation d'une propriété de classe. Ailleurs (dans une méthode, par exemple), le compilateur refuse : NG8110: Unsupported call to the output function. This function can only be called in the initializer of a class member.

Déclarer et émettre une sortie avec output()

  1. Transmettre une valeur avec $event

    • Une sortie peut transporter n'importe quel type : nombre, texte, union, objet. L'objet est souvent le plus pratique, car il regroupe tout ce que le parent doit savoir.
    • Dans le parent, $event peut être combiné à d'autres arguments. Le même composant peut ainsi être utilisé plusieurs fois, pour des usages différents.
import { Component, input, output, signal } from '@angular/core';

export interface Couleur {
  nom: string;
  hex: string;
}

// Composant enfant réutilisable : il émet un objet complet
@Component({
  selector: 'app-palette',
  template: `
    <span class="titre">{{ titre() }}</span>
    @for (c of couleurs; track c.hex) {
      <button [style.background]="c.hex" [title]="c.nom" (click)="choisir.emit(c)"></button>
    }
  `,
  styles: `:host { display: block; margin: 6px 0; } .titre { display: inline-block; width: 60px; }
    button { width: 26px; height: 26px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px #94a3b8; margin-right: 4px; cursor: pointer; }`,
})
export class Palette {
  readonly titre = input('Couleur');
  readonly choisir = output<Couleur>();

  protected readonly couleurs: Couleur[] = [
    { nom: 'Bleu', hex: '#3178c6' }, { nom: 'Rouge', hex: '#dd0031' },
    { nom: 'Vert', hex: '#16a34a' }, { nom: 'Blanc', hex: '#ffffff' }, { nom: 'Noir', hex: '#0f172a' },
  ];
}

// Composant parent : le même enfant, utilisé deux fois, pour deux usages différents
@Component({
  selector: 'app-charge-utile',
  imports: [Palette],
  template: `
    <app-palette titre="Fond" (choisir)="appliquer($event, 'fond')" />
    <app-palette titre="Texte" (choisir)="appliquer($event, 'texte')" />
    <p class="apercu" [style.background]="fond().hex" [style.color]="texte().hex">
      Texte {{ texte().nom }} sur fond {{ fond().nom }}
    </p>
  `,
  styles: `.apercu { padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; }`,
})
export class ChargeUtile {
  protected readonly fond = signal<Couleur>({ nom: 'Blanc', hex: '#ffffff' });
  protected readonly texte = signal<Couleur>({ nom: 'Noir', hex: '#0f172a' });

  protected appliquer(c: Couleur, cible: 'fond' | 'texte'): void {
    (cible === 'fond' ? this.fond : this.texte).set(c);
  }
}
Texte Noir sur fond Blanc
(Fond : bleu, Texte : blanc)   Texte Blanc sur fond Bleu      ← l'aperçu change de couleurs
  • choisir.emit(c) envoie l'objet Couleur complet ; le parent reçoit $event de type Couleur.
  • appliquer($event, 'fond') ajoute une information que seul le parent connaît : à quoi sert cette palette.
  • Si le gestionnaire attend un autre type, le template est refusé : (noter)="afficher($event)" avec afficher(t: string) et une sortie output<number>() donne TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
  • Oublier $event alors que la méthode attend un argument donne TS2554: Expected 1 arguments, but got 0.

Écouter une sortie dans le parent et utiliser $event

  • Attention : une faute de frappe dans le nom d'une sortie ((notr) au lieu de (noter)) ne provoque aucune erreur. Angular croit écouter un événement du navigateur qui n'arrive jamais : le gestionnaire n'est simplement jamais appelé.
  1. Bien nommer une sortie

    • Choisissez un verbe métier: voter, choisir, supprimer, valider. Le guide de style d'Angular déconseille le préfixe on (onVoter).
    • Ne donnez jamais à une sortie le nom d'un événement du navigateur (click, change, input, submit) : l'exemple suivant montre pourquoi.
    • Comme pour les entrées, une option alias donne un nom public différent du nom interne.
import { Component, output, signal } from '@angular/core';

// À NE PAS FAIRE : une sortie qui porte le nom d'un événement du navigateur
@Component({
  selector: 'app-bouton-piege',
  template: `<button (click)="click.emit('sortie')">Bouton piégé</button>`,
})
export class BoutonPiege {
  readonly click = output<string>();
}

// La bonne pratique : un nom métier, et éventuellement un alias public
@Component({
  selector: 'app-bouton-sur',
  template: `<button (click)="appui.emit('sortie')">Bouton sûr</button>`,
})
export class BoutonSur {
  readonly appui = output<string>({ alias: 'valider' }); // nom interne : appui, nom public : valider
}

@Component({
  selector: 'app-nommer-sortie',
  imports: [BoutonPiege, BoutonSur],
  template: `
    <app-bouton-piege (click)="noter('piégé', $event)" />
    <app-bouton-sur (valider)="noter('sûr', $event)" />
    <button (click)="journal.set([])">Vider</button>
    <ol>
      @for (ligne of journal(); track $index) { <li>{{ ligne }}</li> }
    </ol>
  `,
})
export class NommerSortie {
  protected readonly journal = signal<string[]>([]);

  protected noter(bouton: string, e: unknown): void {
    const recu = e instanceof Event ? `${e.constructor.name} du navigateur` : `sortie « ${e} »`;
    this.journal.update((j) => [...j, `${bouton} : ${recu}`]);
  }
}
(clic sur « Bouton piégé »)
1. piégé : sortie « sortie »
2. piégé : PointerEvent du navigateur      ← le gestionnaire est appelé DEUX fois
(clic sur « Bouton sûr »)
3. sûr : sortie « sortie »
  • Le clic natif sur le <button> intérieur remonte (bouillonnement du DOM) jusqu'à la balise <app-bouton-piege>. Le parent reçoit donc la sortie et l'événement du navigateur, avec deux $event de types différents.
  • output({ alias: 'valider' }): la classe utilise this.appui, le parent écoute (valider). Réservez l'alias aux cas où le nom public est imposé ; sinon, un seul nom est plus lisible.

Bien nommer une sortie Angular

  1. Remonter un événement sur plusieurs niveaux

    • Contrairement aux événements du navigateur, une sortie Angular ne bouillonne pas: seul le parent direct peut l'écouter. Pour atteindre un composant plus haut, chaque niveau intermédiaire doit relayer l'événement avec sa propre sortie.
    • Ce schéma s'accompagne d'une règle d'or : les données descendent, les événements remontent. Un seul composant possède les données ; c'est le seul à les modifier.
import { Component, computed, input, output, signal } from '@angular/core';

export interface Tache {
  id: number;
  titre: string;
  faite: boolean;
}

// Petit-enfant : une ligne. Il ne connaît que SA tâche
@Component({
  selector: 'app-item-tache',
  template: `
    <label [class.faite]="tache().faite">
      <input type="checkbox" [checked]="tache().faite" (change)="basculer.emit()" />
      {{ tache().titre }}
    </label>
    <button (click)="supprimer.emit()">✕</button>
  `,
  styles: `:host { display: flex; justify-content: space-between; max-width: 320px; } .faite { text-decoration: line-through; color: #94a3b8; }`,
})
export class ItemTache {
  readonly tache = input.required<Tache>();
  readonly basculer = output<void>();
  readonly supprimer = output<void>();
}

// Enfant : la liste. Il RELAIE les événements en ajoutant l'identifiant
@Component({
  selector: 'app-liste-taches',
  imports: [ItemTache],
  template: `
    @for (t of taches(); track t.id) {
      <app-item-tache [tache]="t" (basculer)="basculer.emit(t.id)" (supprimer)="supprimer.emit(t.id)" />
    } @empty {
      <p>Rien à faire.</p>
    }
  `,
})
export class ListeTaches {
  readonly taches = input.required<Tache[]>();
  readonly basculer = output<number>();
  readonly supprimer = output<number>();
}

// Parent : le SEUL à posséder et à modifier les données
@Component({
  selector: 'app-remonter-etat',
  imports: [ListeTaches],
  template: `
    <app-liste-taches [taches]="taches()" (basculer)="basculer($event)" (supprimer)="supprimer($event)" />
    <p>{{ faites() }} / {{ taches().length }} terminée(s)</p>
  `,
})
export class RemonterEtat {
  protected readonly taches = signal<Tache[]>([
    { id: 1, titre: 'Lire le chapitre', faite: true },
    { id: 2, titre: 'Faire les activités', faite: false },
    { id: 3, titre: 'Rendre le TP', faite: false },
  ]);
  protected readonly faites = computed(() => this.taches().filter((t) => t.faite).length);

  protected basculer(id: number): void {
    this.taches.update((l) => l.map((t) => (t.id === id ? { ...t, faite: !t.faite } : t)));
  }

  protected supprimer(id: number): void {
    this.taches.update((l) => l.filter((t) => t.id !== id));
  }
}
Lire le chapitre (barré) · Faire les activités · Rendre le TP      1 / 3 terminée(s)
(case « Faire les activités »)     2 / 3 terminée(s)
(✕ sur « Lire le chapitre »)       Faire les activités · Rendre le TP      1 / 2 terminée(s)

Relayer une sortie sur plusieurs niveaux

  • ItemTache émet basculer sans valeur : il ne connaît que sa tâche. ListeTaches relaie en ajoutant l'identifiant (basculer.emit(t.id)) : c'est elle qui sait de quelle tâche il s'agit.
  • Seul RemonterEtat modifie le signal taches, toujours avec un nouveau tableau (map, filter) : les entrées des enfants sont alors bien mises à jour.
  • Quand il faut traverser beaucoup de niveaux, relayer devient lourd : on utilise alors un service partagé (voir « La communication entre composants Angular »).
  1. S'abonner à une sortie par le code

    • Le plus souvent, on écoute une sortie dans le template. Mais output() renvoie un objet OutputEmitterRef qui possède aussi une méthode subscribe(): un parent peut s'y abonner par le code, après avoir obtenu l'enfant avec viewChild().
    • La fonction outputToObservable() (paquet @angular/core/rxjs-interop) transforme une sortie en Observable RxJS, pour utiliser ses opérateurs (filter, map, debounceTime…). À l'inverse, outputFromObservable() crée une sortie à partir d'un Observable (activité 4).
import { Component, DestroyRef, afterNextRender, inject, output, signal, viewChild } from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';

// Enfant : un compte à rebours qui émet à chaque seconde, puis à la fin
@Component({
  selector: 'app-minuteur',
  template: `<button (click)="demarrer()" [disabled]="reste() > 0">Démarrer</button> <strong>{{ reste() }} s</strong>`,
})
export class Minuteur {
  readonly tic = output<number>();
  readonly termine = output<void>();
  protected readonly reste = signal(0);
  private id?: ReturnType<typeof setInterval>;

  constructor() {
    inject(DestroyRef).onDestroy(() => clearInterval(this.id)); // arrêter si le composant disparaît
  }

  protected demarrer(): void {
    this.reste.set(4);
    this.id = setInterval(() => {
      this.reste.update((r) => r - 1);
      this.tic.emit(this.reste());
      if (this.reste() === 0) {
        clearInterval(this.id);
        this.termine.emit();
      }
    }, 1000);
  }
}

// Parent : il s'abonne aux sorties par le code, et non dans le template
@Component({
  selector: 'app-abonnement-code',
  imports: [Minuteur],
  template: `<app-minuteur /> <p>{{ journal().join(' · ') }}</p>`,
})
export class AbonnementCode {
  private readonly minuteur = viewChild.required(Minuteur);
  protected readonly journal = signal<string[]>([]);

  constructor() {
    afterNextRender(() => {
      // 1. subscribe() : désabonnement automatique quand le minuteur est détruit
      this.minuteur().termine.subscribe(() => this.noter('terminé !'));
      // 2. outputToObservable() : la sortie devient un Observable RxJS
      outputToObservable(this.minuteur().tic)
        .pipe(filter((r) => r % 2 === 0))
        .subscribe((r) => this.noter(`${r} s (pair)`));
    });
  }

  private noter(texte: string): void {
    this.journal.update((j) => [...j, texte]);
  }
}
(Démarrer)  4 s … 3 s … 2 s … 1 s … 0 s
2 s (pair) · 0 s (pair) · terminé !
  • afterNextRender attend que la vue soit affichée : avant, l'enfant n'existe pas encore et viewChild.required ne peut pas le fournir.
  • Les abonnements faits avec subscribe() sont automatiquement supprimés quand le composant enfant est détruit : pas de fuite de mémoire.
  • Inversement, émettre depuis un composant déjà détruit (par exemple dans un setTimeout oublié) provoque une erreur à l'exécution : NG0953: Unexpected emit for destroyed `OutputRef`. The owning directive/component is destroyed. D'où le DestroyRef.onDestroy qui arrête le minuteur.
  • Cette technique reste exceptionnelle : écouter dans le template est plus simple et plus lisible.
  1. Le couple valeur / valeurChange et [( )]

    • Une entrée actif associée à une sortie nommée exactement actifChange forme un couple reconnu par Angular : le parent peut alors écrire la liaison bidirectionnelle [(actif)]="wifi".
    • C'est ce mécanisme que la fonction model(), vue dans « Le two-way data binding et ngModel », crée en une seule ligne.
import { Component, input, output, signal } from '@angular/core';

// Une entrée « actif » + une sortie « actifChange » : le couple permet [(actif)]
@Component({
  selector: 'app-interrupteur',
  template: `
    <button role="switch" [class.on]="actif()" (click)="actifChange.emit(!actif())">
      {{ actif() ? 'Activé' : 'Désactivé' }}
    </button>
  `,
  styles: `button { border-radius: 14px; padding: 4px 14px; border: 1px solid #94a3b8; } .on { background: #16a34a; color: #fff; }`,
})
export class Interrupteur {
  readonly actif = input(false);
  readonly actifChange = output<boolean>();
}

@Component({
  selector: 'app-sortie-change',
  imports: [Interrupteur],
  template: `
    <p>Écriture longue : <app-interrupteur [actif]="wifi()" (actifChange)="wifi.set($event)" /></p>
    <p>Écriture courte : <app-interrupteur [(actif)]="wifi" /></p>
    <p>Wi-Fi : {{ wifi() ? 'oui' : 'non' }}</p>
  `,
})
export class SortieChange {
  protected readonly wifi = signal(false);
}
Écriture longue : Désactivé · Écriture courte : Désactivé · Wi-Fi : non
(clic sur le 1er interrupteur)   Activé · Activé · Wi-Fi : oui
(clic sur le 2e interrupteur)    Désactivé · Désactivé · Wi-Fi : non
  • [(actif)]="wifi" est un raccourci pour [actif]="wifi()" (actifChange)="wifi.set($event)". Les deux interrupteurs partagent le même signal : ils restent synchronisés.
  • L'interrupteur ne modifie pas son entrée (c'est impossible) : il propose la nouvelle valeur par sa sortie, et le parent l'accepte.
  • Avec readonly actif = model(false);, l'enfant pourrait écrire this.actif.set(…) directement. Utilisez model() pour un vrai champ de saisie, et le couple input / output quand l'enfant doit seulement proposer une valeur.
  1. L'ancienne écriture : @Output() et EventEmitter

    • Avant output(), une sortie était une propriété décorée par @Output() (voir « Les décorateurs Angular ») et initialisée avec un new EventEmitter<T>(). Le premier argument de @Output() est l'alias.
    • Côté parent, rien ne change : (valeurChange), (reinitialiser) et [(valeur)] s'écrivent de la même façon.
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';

// L'ancienne écriture : @Output() + EventEmitter
@Component({
  selector: 'app-selecteur-ancien',
  template: `
    @for (o of options; track o) {
      <button [class.actif]="o === valeur" (click)="choisir(o)">{{ o }}</button>
    }
    <button (click)="vider.emit()">Aucun</button>
  `,
  styles: `.actif { background: #3178c6; color: #fff; }`,
})
export class SelecteurAncien {
  @Input() options: string[] = [];
  @Input() valeur = '';
  @Output() valeurChange = new EventEmitter<string>();
  @Output('reinitialiser') vider = new EventEmitter<void>(); // alias : nom public « reinitialiser »

  protected choisir(o: string): void {
    this.valeurChange.emit(o);
  }
}

@Component({
  selector: 'app-ancienne-ecriture',
  imports: [SelecteurAncien],
  template: `
    <app-selecteur-ancien [options]="tailles" [(valeur)]="taille" (reinitialiser)="taille = ''" />
    <p>Taille choisie : {{ taille || 'aucune' }}</p>
  `,
})
export class AncienneEcriture {
  protected readonly tailles = ['S', 'M', 'L', 'XL'];
  protected taille = 'M';
}
Taille choisie : M         (bouton M en bleu)
(XL)      Taille choisie : XL
(Aucun)   Taille choisie : aucune

Correspondance entre @Output() + EventEmitter et output()

  • EventEmitter hérite de Subject (RxJS) : il apporte toute la bibliothèque RxJS, alors que output() n'en a pas besoin.
  • Sa méthode est déclarée emit(value?: T): l'argument est facultatif. valeurChange.emit() sans valeur passe donc la compilation et envoie undefined. Avec output<number>(), la même erreur est détectée : TS2554: Expected 1 arguments, but got 0.
  • Rien n'empêche de remplacer l'EventEmitter ou de lui ajouter des abonnés à la main ; avec output(), le seul usage possible est emit() (écrire this.noter.set(3) donne TS2339: Property 'set' does not exist on type 'OutputEmitterRef<number>'.).
  • Angular fournit une migration automatique : ng generate @angular/core:output-migration.
  1. Aide-mémoire

Aide-mémoire de output() dans Angular

  1. Activité 1 : une notation par étoiles

    • Créer le composant app-notation-etoiles:
      • entrées note (0 par défaut) et max (5 par défaut), avec numberAttribute;
      • entrée lectureSeule avec booleanAttribute, qui désactive les étoiles ;
      • sortie noter = output<number>(), émise au clic sur une étoile ;
      • au survol, les étoiles se remplissent jusqu'à l'étoile pointée (prévisualisation avec un signal survol), sans rien émettre.
    • Le parent app-avis-produits affiche trois produits (Clavier 4, Souris 0, Écran 3), met à jour la note du produit cliqué (de façon immuable) et affiche une ligne « Moyenne » avec une notation en lecture seule et « Dernière action : Souris noté 5 / 5 ». Les produits sans note ne comptent pas dans la moyenne.

Résultat attendu de l'activité 1 : la notation par étoiles

  • Vérifier : au départ la moyenne vaut 3.5 ; après Souris = 5 elle vaut 4.0 ; après Clavier = 2 elle vaut 3.3.
  • Pourquoi la notation ne modifie-t-elle pas elle-même sa note ? Que faudrait-il changer pour pouvoir écrire [(note)]?
  1. Activité 2 : un digicode

    • Créer app-pave-numerique: 10 chiffres, une touche C et une touche OK. Le composant ne stocke rien: il émet touche (le chiffre, en texte), effacer et valider (sans valeur). Une entrée bloque désactive toutes les touches.
    • Le parent app-digicode possède la saisie (4 chiffres au plus, affichés masqués ••__) et vérifie le code secret 2026:
      • moins de 4 chiffres : « Le code doit comporter 4 chiffres. » ;
      • code faux : « Code faux (1 / 3). », en rouge ; après 3 erreurs, « Clavier bloqué : 3 erreurs. » et le pavé est bloqué ;
      • bon code : « Porte ouverte. », en vert, et le pavé est bloqué.

Résultat attendu de l'activité 2 : le digicode

  • Pourquoi le code secret ne doit-il pas être placé dans le pavé numérique ?
  • Réutiliser le même pavé dans une calculatrice : qu'est-ce qui change, et qu'est-ce qui reste identique ?
  1. Activité 3 : une boîte de confirmation

    • Créer app-boite-confirmation: entrée obligatoire message, entrée libelleConfirmer (« Confirmer » par défaut), sorties confirmer et annuler (sans valeur). La touche Échap émet aussi annuler (écouteur document:keydown.escape dans host).
    • Le parent app-fichiers-confirmation affiche une liste de fichiers avec un bouton « Supprimer ». Le clic ne supprime pas : il ouvre la boîte (« Supprimer « notes.txt » définitivement ? »). Les boutons « Supprimer » de la liste sont désactivés tant que la boîte est ouverte.
    • Confirmer retire le fichier et affiche « « rapport.pdf » supprimé. » ; Annuler ou Échap ferme la boîte et affiche « Suppression de « notes.txt » annulée. ».

Résultat attendu de l'activité 3 : la boîte de confirmation

  • Comment le parent sait-il quel fichier supprimer, alors que confirmer n'émet aucune valeur ?
  • Réutiliser la boîte pour « Vider le dossier » avec libelleConfirmer="Tout vider".
  1. Activité 4 : une recherche avec délai

    • Créer app-champ-recherche: un champ et un bouton « Effacer ». À chaque frappe, le texte est poussé dans un Subject<string>. La sortie rechercher est créée avec outputFromObservable(): elle n'émet qu'après 400 ms sans frappe (debounceTime), un texte nettoyé (trim, minuscules) et jamais deux fois le même terme (distinctUntilChanged).
    • Deux autres sorties : touche (à chaque frappe) et effacer.
    • Le parent app-recherche-delai filtre une liste de 8 chapitres du cours et affiche « Touches : 5 · recherches émises : 1 · terme : « les s » ».

Résultat attendu de l'activité 4 : la recherche avec délai

  • Taper « les s » rapidement : combien de touches, combien de recherches ? Effacer le dernier « s » et le retaper : pourquoi aucune nouvelle recherche n'est-elle émise ?
  • Taper « ÉVÉN » : quels chapitres s'affichent ?
  1. Activité 5 : migrer @Output() vers output()

    • On vous fournit le compteur ci-dessous, écrit avec l'ancienne syntaxe :
@Component({
  selector: 'app-compteur-ancien',
  template: `<button (click)="changer(-pas)">−</button> {{ valeur }} <button (click)="changer(pas)">+</button>`,
})
export class CompteurAncien {
  @Input() valeur = 0;
  @Input({ transform: numberAttribute }) pas = 1;
  @Input({ transform: numberAttribute }) max = 10;
  @Output() valeurChange = new EventEmitter<number>();
  @Output('limite') limiteAtteinte = new EventEmitter<void>();

  changer(delta: number): void {
    const v = Math.min(this.max, Math.max(0, this.valeur + delta));
    this.valeurChange.emit(v);
    if (v === this.max && v !== this.valeur) this.limiteAtteinte.emit(); // seulement en atteignant la limite
  }
}
  • Écrire app-compteur, son équivalent avec input() et output() (en gardant l'alias public limite).
  • Afficher les deux versions dans un tableau, liées aux mêmes signaux avec [(valeur)]: « Places » (3, max 5) et « Budget » (50, pas 25, max 100). Les deux colonnes doivent toujours afficher la même valeur, et chaque limite atteinte est notée (« ancien » ou « nouveau »).

Résultat attendu de l'activité 5 : avant et après la migration

  • Que change la migration côté parent ? Et pour la vérification de emit()?
  • Lancer ng generate @angular/core:output-migration sur une copie de CompteurAncien et comparer avec votre version.
  • Les corrigés des cinq activités, testés avec Angular 22, sont fournis dans l'archive de la leçon (dossier corriges/).
  1. Erreurs fréquentes

Message ou symptôme Cause Solution
TS2554: Expected 1 arguments, but got 0. (sur emit()) emit() sans valeur sur une sortie typée Passer la valeur, ou déclarer output<void>()
TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. Valeur émise du mauvais type Émettre une valeur du type de output<T>()
TS2345: Argument of type 'number' is not assignable to parameter of type 'string'. $event passé à une méthode qui attend un autre type Accorder le type du paramètre et celui de la sortie
TS2554: Expected 1 arguments, but got 0. (dans le template) $event oublié : (voter)="compter()" (voter)="compter($event)"
TS2339: Property 'set' does not exist on type 'OutputEmitterRef<number>'. Sortie utilisée comme un signal Utiliser emit()
NG8110: Unsupported call to the output function. This function can only be called in the initializer of a class member. output() appelée dans une méthode ou le constructeur La déclarer comme propriété de classe
NG8001: 'app-boutons-vote' is not a known element Composant enfant non importé L'ajouter dans imports du parent
NG0953: Unexpected emit for destroyed `OutputRef`. Émission après la destruction (minuteur, requête) Arrêter le traitement dans DestroyRef.onDestroy
Le gestionnaire n'est jamais appelé, sans erreur Faute de frappe dans le nom : (notr) Vérifier le nom (ou l'alias) de la sortie
Le gestionnaire est appelé deux fois Sortie nommée click, change… comme un événement DOM Lui donner un nom métier
Le grand-parent ne reçoit rien Les sorties ne bouillonnent pas Relayer à chaque niveau, ou utiliser un service
emit() envoie undefined sans erreur EventEmitter.emit(value?: T): argument facultatif Migrer vers output()
  1. À retenir

    • output<T>() déclare une sortie ; l'enfant émet avec emit(valeur); output<void>() n'émet aucune valeur.
    • Le parent écoute avec (nom)="instruction"; $event contient la valeur émise, typée et vérifiée par le compilateur.
    • L'enfant annonce, le parent décide : les données descendent par les entrées, les événements remontent par les sorties.
    • Une sortie ne remonte que d'un niveau : il faut la relayer, ou passer par un service.
    • Un nom métier, jamais un événement du navigateur ; alias seulement si nécessaire.
    • valeur + valeurChange permet [(valeur)]; model() en est le raccourci.
    • subscribe(), outputToObservable() et outputFromObservable() font le lien avec le code et RxJS.
    • @Output() + EventEmitter est l'ancienne écriture : à connaître, à migrer vers output().
Un bon composant enfant ne sait pas à quoi il sert : il affiche ce qu'on lui donne et annonce ce qui se passe. C'est ce qui le rend réutilisable.