input(), output() et model() entre composants
À retenir —
input()reçoit des données du parent sous forme de signal,output()émet des événements vers le parent, etmodel()crée une liaison bidirectionnelle[(valeur)]. La communication entre composants devient entièrement réactive.
Jusqu'ici, nos signals vivaient dans un seul composant. Voyons maintenant comment faire communiquer un parent et un enfant avec les API à base de signals, qui remplacent avantageusement les décorateurs @Input() et @Output() historiques.
input() : recevoir des données en signal
La fonction input() déclare une entrée du composant. Contrairement à @Input(), elle renvoie un signal en lecture seule : on lit la valeur reçue du parent en l'appelant.
import { Component, input } from '@angular/core';
@Component({
selector: 'app-badge',
standalone: true,
template: `<span>{{ libelle() }} ({{ niveau() }})</span>`,
})
export class BadgeComponent {
readonly libelle = input.required<string>(); // entrée OBLIGATOIRE
readonly niveau = input(1); // entrée avec valeur par défaut
}
Côté parent :
<app-badge libelle="Expert" [niveau]="3" />
Deux variantes utiles :
input.required<T>(): l'entrée est obligatoire. Si le parent l'oublie, Angular le signale à la compilation (avecstrictTemplates).- Transformation :
input(0, { transform: ... })pour convertir la valeur reçue. Exemple courant, accepterdisabledsans valeur :
import { booleanAttribute, numberAttribute } from '@angular/core';
readonly desactive = input(false, { transform: booleanAttribute }); // <cmp desactive>
readonly age = input(0, { transform: numberAttribute }); // age="42" → 42
Comme input() renvoie un signal, on peut le dériver directement avec computed() :
readonly prix = input.required<number>();
readonly prixTTC = computed(() => this.prix() * 1.2);
output() : émettre des événements vers le parent
output() déclare un événement de sortie typé. On le déclenche avec .emit(valeur) :
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-note',
standalone: true,
template: `
<p>Note : {{ valeur() }} / 5</p>
<button (click)="noter.emit(valeur() + 1)" [disabled]="valeur() >= 5">
Augmenter
</button>
`,
})
export class NoteComponent {
readonly valeur = input(0);
readonly noter = output<number>(); // OutputEmitterRef<number>
}
Côté parent, on écoute comme un événement classique :
<app-note [valeur]="qualite()" (noter)="qualite.set($event)" />
output() est plus simple qu'un EventEmitter : pas d'héritage RxJS, juste emit(). Voici l'exemple parent-enfant complet, exécutable :
import { Component, input, output, signal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-note',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article>
<h3>{{ titre() }}</h3>
<p>Note : {{ valeur() }} / 5</p>
<button (click)="noter.emit(valeur() + 1)" [disabled]="valeur() >= 5">
Augmenter
</button>
</article>
`,
})
export class NoteComponent {
readonly titre = input.required<string>();
readonly valeur = input(0);
readonly noter = output<number>();
}
@Component({
selector: 'app-root',
standalone: true,
imports: [NoteComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Avis produit</h1>
<app-note titre="Qualité" [valeur]="qualite()" (noter)="qualite.set($event)" />
<p>Note enregistrée : {{ qualite() }} / 5</p>
`,
})
export class AppComponent {
readonly qualite = signal(3);
}
Essayez en direct
input-output-note
model() : la liaison bidirectionnelle
model() combine une entrée et une sortie en un seul signal modifiable des deux côtés. C'est l'équivalent moderne du couple @Input() + @Output() qui rendait possible le [(banana-in-a-box)].
À l'intérieur du composant, un model est un signal writable : on le lit et on lui applique set/update. Côté parent, on le lie avec la syntaxe [(...)] :
import { Component, input, model, signal, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-champ',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label>
{{ libelle() }} :
<input [(ngModel)]="valeur" />
</label>
<button (click)="valeur.set('')">Effacer</button>
`,
})
export class ChampComponent {
readonly libelle = input('Champ');
readonly valeur = model(''); // ModelSignal<string>
}
@Component({
selector: 'app-root',
standalone: true,
imports: [ChampComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Formulaire</h1>
<app-champ libelle="Pseudo" [(valeur)]="pseudo" />
<p>Bonjour {{ pseudo() || 'inconnu' }} 👋</p>
`,
})
export class AppComponent {
readonly pseudo = signal('');
}
Quand l'utilisateur tape dans le champ, valeur change dans l'enfant, ce qui propage la nouvelle valeur au pseudo du parent — et inversement. Un seul signal, deux sens.
Essayez en direct
model-champ
input/output/model : lequel choisir ?
| Besoin | API | Liaison parent |
|---|---|---|
| Recevoir une donnée du parent | input() |
[valeur] |
| Notifier le parent d'un événement | output() |
(evenement) |
| Donnée modifiable des deux côtés | model() |
[(valeur)] |
Règle simple : commencez par input() + output(). Ne passez à model() que lorsqu'une même valeur doit réellement être pilotée par le parent ET modifiée par l'enfant (champs de formulaire réutilisables, composants de type toggle, slider, date-picker).
En résumé
input()/input.required(): entrées en signal, dérivables aveccomputed, transformables viatransform.output(): événements typés émis avec.emit(), sans RxJS.model(): liaison bidirectionnelle[(...)], un signal writable partagé parent-enfant.
Au chapitre 7, on connecte les signals au monde asynchrone avec l'interop RxJS : toSignal(), toObservable() et le pattern resource().