Avec les versions 17 à 17.3, Angular a introduit des fonctions pour déclarer des inputs et des outputs sous forme de signaux dans les composants.
Jusqu’alors, la seule option pour ce faire était d’utiliser les décorateurs @Input et @Output.
Même si les décorateurs sont encore supportés, la recommandation d’Angular est d’utiliser les inputs et outputs sous forme de signaux.
Je vous partage dans cet article les différences en matière de syntaxe entre ces deux méthodes et également mon retour d’expérience sur une migration pour remplacer les décorateurs par les signaux.
Comprendre les décorateurs @Input et @Output
Ces décorateurs sont utilisés pour donner des entrants à un composant et lui permettre d’émettre des événements, pour que le composant parent réagisse quand ces événements se produisent.
Composant d’édition d’une valeur
Le cas d’utilisation le plus simple pour ces deux décorateurs est de créer un composant :
- à qui on donnerait une valeur initiale
- qui permettrait de modifier cette valeur
- et qui renverrait ensuite cette valeur à son parent
Voici un exemple de composant simple qui permet d’éditer un compteur, et qu’on utilisera tout du long cet article :
app.html
<app-counter [(counter)]="globalCounter"></app-counter>
counter.component.html
Counter:
<input [(ngModel)]="counter" (ngModelChange)="onUpdate()" />
counter.component.ts
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
@Input({required: true})
counter: number = 0;
@Output()
counterChange = new EventEmitter<number>()
onUpdate() {
this.counterChange.emit(this.counter);
}
}
On utilise le décorateur @Input sur l’attribut counter afin de déclarer la propriété comme modifiable de l’extérieur : à chaque changement de la valeur donnée à counter par App (globalCounter), le changement sera répercuté dans CounterComponent.
Comme le composant CounterComponent n’aurait pas de sens, si on ne lui donnait pas de counter, on précise que la propriété counter est obligatoire via l’option required: true.
Le décorateur @Output nous permet d’avertir le parent des mises à jour du compteur en lui passant la nouvelle valeur du compteur.
Comme on a suivi la convention de nommage sur l’attribut d’output en l’appelant avec {attribut}Change, on bénéficie du binding bidirectionnel depuis le parent.
Calcul d’une valeur à partir d’un input
Il arrive souvent qu’on ait besoin de calculer des valeurs à partir d’un input, par exemple pour afficher des informations supplémentaires dans une liste d’éléments ou encore afficher des avertissements si les valeurs entrées ne sont pas cohérentes avec l’attendu.
Nous allons modifier notre composant de compteur, afin d’afficher un message d’avertissement s’il est plus élevé qu’un maximum donné.
counter.component.ts
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent implements OnChanges {
public readonly max = 100;
@Input({required: true})
counter: number = 0;
isCounterTooHigh: boolean = false;
// C'est une première implémentation qui présente pas mal de défauts
// Ne faites pas ça chez vous :)
ngOnChanges(changes: SimpleChanges): void {
if (changes['counter']) {
this.isCounterTooHigh = this.counter > this.max;
}
}
}
counter.component.html
Counter:
<input [(ngModel)]="counter" />
@if(isCounterTooHigh) {
Counter's value is too high, it should be less than {{max}}.
}
L’utilisation de ngOnChanges nous permet de mettre à jour isCounterTooHigh à chaque fois que le composant reçoit une nouvelle valeur pour counter depuis son parent (sous-entendu: à chaque fois que globalCounter est mis à jour).
Cette approche présente le défaut que ngOnChanges est appelée à chaque fois que l’un des inputs du composant est modifié, ce qui peut être lourd si le composant a de nombreux inputs.
Un autre défaut de l’utilisation du ngOnChanges est que si counter est modifié “depuis l’intérieur” du composant (sans que cela ne vienne du parent), cela ne déclenchera pas de détection du changement et isCounterTooHigh ne sera pas mis à jour (ngOnChanges n’est appelée qu’après un changement “descendant” du parent).
Enfin, ce code n’est pas du tout typesafe: si l’attribut counter venait à être renommé, le compilateur ne vous avertira pas que le changes['counter'] est invalide, entraînant une régression (le champ isCounterTooHigh ne sera jamais mis à jour).
Si on veut qu’il soit toujours cohérent avec la valeur du compteur, il vaut mieux utiliser un setter sur l’input, qui garantira qu’une modification de counter aussi bien de l’intérieur que de l’extérieur déclenchera un re-calcul du isCounterTooHigh, comme ceci :
counter.component.ts
import {Component, Input} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
private readonly max = 100;
private _counter: number = 0;
@Input({required: true})
set counter(value: number) {
this._counter = value;
this.isCounterTooHigh = this.counter > this.max;
}
get counter(): number {
return this._counter;
}
isCounterTooHigh: boolean = false;
}
À noter que l’introduction du setter nous contraint à avoir un getter si l’on souhaite garder une cohérence de nommage entre une lecture de l’attribut et une écriture.
À noter également: rien n’empêche de mettre une valeur arbitraire à isCounterTooHigh dans une méthode autre, la cohérence de ce champ n’est donc ici pas garantie (il faut bien inspecter tous les usages en écriture de cette propriété pour obtenir cette garantie).
Une alternative à l’approche du setter + getter aurait pu être de transformer le champ isCounterTooHigh en une méthode (ou un getter) :
counter.component.ts
import {Component, Input} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
private readonly max = 100;
@Input({required: true})
counter: number = 0;
isCounterTooHigh(): boolean {
return this.counter > this.max;
}
}
counter.component.html
Counter:
<input [(ngModel)]="counter" />
@if(isCounterTooHigh()) {
Counter's value is too high, it should be less than {{max}}.
}
Cependant, pour des raisons de performances, il est préférable d’éviter autant que possible les appels de fonctions dans les templates Angular (en dehors des callbacks d’évènements évidemment).
Cet article peut vous expliquer pourquoi.
Contraintes d’utilisation
À travers les exemples précédents, nous avons vu les défauts suivants :
- il manque un décorateur unique pour offrir un binding bidirectionnel : il faut systématiquement utiliser
@Inputet@Output, en respectant bien la convention de nommage pour@Outputafin de bénéficier du binding bidirectionnel - pour calculer des valeurs dérivées d’inputs, il faut s’armer d’un setter + getter ou utiliser
ngOnChanges, qui s’exécute uniquement sur un changement “descendant” d’un des inputs du parent
Bien que fonctionnelle, l’approche des décorateurs @Input et @Output reste très verbeuse pour traiter ces cas, qui ne sont pas rares dans le développement d’applications web.
De plus, sur le cas de la valeur dérivée d’un input, on manque de garde-fou pour s’assurer que la valeur dérivée n’est pas changée sans cohérence avec l’input dont elle dérive.
Explorons maintenant la manière de répondre à ces cas avec la syntaxe de déclaration par fonctions.
Les fonctions input, model et output
input et output
La nouvelle manière de déclarer des inputs et outputs consiste à initialiser des attributs avec les fonctions input et output au sein du composant.
Ces fonctions créent des signaux, pour lesquels le binding fonctionne de la même manière qu’avec les décorateurs.
Si on reprend le premier exemple des décorateurs avec les fonctions input :
app.html
<app-counter [(counter)]="globalCounter"></app-counter>
counter.component.html
Counter:
<input [ngModel]="counter()" (ngModelChange)="updateCounter($event)" />
counter.component.ts
import {Component, input, output} from '@angular/core';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
public readonly counter = input.required<number>();
public readonly counterChange = output<number>()
updateCounter(newValue: number) {
this.counterUpdated.emit(newValue);
}
}
La syntaxe est assez proche, elle permet de s’abstenir des décorateurs (qui, à l’heure où ces lignes sont écrites, ne sont toujours qu’au stage 3/4 du TC39 et ne peuvent donc pas encore être considérés comme une feature “native” à ECMAScript) mais, pour l’instant, on ne voit pas de changement majeur.
On retrouve bien la possibilité de préciser que l’input est obligatoire, en utilisant input.required : dans ce cas, il est impossible de préciser une valeur initiale de l’input (au passage, cela permet d’avoir un meilleur typage Typescript en évitant d’utiliser un ! ou d’accepter une valeur impossible du type undefined ou une valeur arbitraire qui ne seraient utiles que pour éviter de faire râler le compilateur Typescript).
Ce n’est pas visible dans cet exemple mais, de la même manière que c’était possible avec @Input, on peut préciser une fonction pour transformer l’input quand on le reçoit.
Les attributs sont readonly parce que ce sont des signaux : pour les mettre à jour, on utilise les fonctions de l’API WritableSignal (set et update).
On peut noter ici que le signal counter est un signal immutable.
Par conséquent, le binding bidirectionnel que l’on pouvait faire dans counter.component.html avec les décorateurs n’est plus possible, ce qui oblige à récupérer l’événement dans ngModelChange pour l’appel à updateCounter.
model
En plus de input et output, la fonction model a été ajoutée pour créer un attribut sur lequel il y a du binding bidirectionnel : il s’agit d’un input et d’un output en même temps.
Le binding sortant est créé automatiquement et s’appelle {attribut}Change, ce qui correspond à la convention proposée pour nommer les signaux outputs.
En utilisant cette fonction, notre composant de compteur se simplifie :
app.html
<app-counter [(counter)]="globalCounter"></app-counter>
counter.component.html
Counter:
<input [(ngModel)]="counter" />
counter.component.ts
import {Component, model} from '@angular/core';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
public readonly counter = model.required<number>();
}
Parce que model crée un WritableSignal, on peut donner directement le signal en double binding à l’input : l’écriture est alors gérée automatiquement par le binding sortant.
Cette nouvelle méthode de déclaration des bindings permet de réduire le boilerplate pour du binding bidirectionnel, ce qui est très appréciable.
Calcul d’une valeur à partir d’un input
Comme le retour de la fonction input est un signal, on peut utiliser tous les utilitaires des signaux, comme faire un signal dérivé à l’aide de computed ou réagir à la modification de la valeur avec effect.
Si l’on profite de ces avantages pour réécrire isCounterTooHigh, le composant devient très simple :
import {Component, computed, model} from '@angular/core';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css'],
standalone: true,
imports: [FormsModule]
})
export class CounterComponent {
private readonly max = 100;
public readonly counter = model.required<number>();
public readonly isCounterTooHigh = computed(() => this.counter() > this.max)
}
En plus d’une écriture bien plus courte que l’équivalent avec les décorateurs @Input et @Output, on voit très facilement d’où provient la valeur de isCounterTooHigh et on prévient toute mise à jour de sa valeur la rendant incohérente avec counter (le signal est en read-only).
Migrer vers les signaux Angular
Pourquoi migrer dès aujourd’hui ?
Après avoir été introduits en 17.3, les fonctions input, model et output sont devenues stables depuis Angular 19.
La version la plus récente d’Angular à ce jour, Angular 20, supporte encore les décorateurs @Input et @Output : il n’y a donc pas d’urgence à migrer sur vos projets, même si la documentation recommande l’utilisation de signaux pour de nouveaux projets.
Comme nous l’avons vu, la syntaxe utilisant les signaux permet de réduire énormément le boilerplate et de bénéficier de l’API des signaux, ce qui permet de créer très facilement et lisiblement des valeurs dérivées d’inputs.
Migrer les inputs avec la CLI
Afin de faciliter la migration, Angular met à disposition des migrations automatiques, directement dans la CLI.
La migration des inputs est documentée sur cette page : https://angular.dev/reference/migrations/signal-inputs.
Voici un exemple d’utilisation de cet utilitaire, sur le projet utilisé en exemple dans cet article :
➜ my-app-angular-20 git:(master) ✗ npx @angular/cli@20 generate @angular/core:signal-input-migration
✔ Which directory do you want to migrate? ./
✔ Do you want to migrate as much as possible, even if it may break your build? Yes
Preparing analysis for: tsconfig.app.json...
Scanning for inputs: tsconfig.app.json...
Preparing analysis for: tsconfig.spec.json...
Scanning for inputs: tsconfig.spec.json...
Processing analysis data between targets...
Running migration for: tsconfig.app.json...
Running migration for: tsconfig.spec.json...
Successfully migrated to signal inputs 🎉
-> Migrated 1/1 inputs.
You ran with best effort mode. Manually verify all code works as intended, and fix where necessary.
UPDATE src/app/counter/counter.component.ts (492 bytes)
UPDATE src/app/counter/counter.component.html (73 bytes)
L’utilitaire modifie les fichiers Typescript (pour remplacer le décorateur par l’appel de input) mais aussi le fichier HTML correspondant, pour invoquer la valeur du signal correctement.
Pour les cas simples d’input sans options, ou avec les options required et alias , la migration automatique s’occupera de tout pour vous.
Elle présente cependant quelques limites :
- si vous déclarez des inputs avec
transformsans typage explicite, il y aura une erreur lors de l’exécution de la migration mais votre input sera quand même migré, en perdant sa fonction de transformation 😱.
Ce problème a été signalé sur le github d’angular : signal-input-migration on transform without static typing · Issue #63541 · angular/angular
Par exemple :
my-component.ts
@Input({transform: value => value * 10})
counter: number = 0;
Lorsqu’on lance la migration, on a une erreur :
➜ src git:(master) ✗ npx @angular/cli@20 generate @angular/core:signal-input-migration
✔ Which directory do you want to migrate? ./
✔ Do you want to migrate as much as possible, even if it may break your build? No
Preparing analysis for: tsconfig.app.json...
Scanning for inputs: tsconfig.app.json...
src/app/counter/counter.component.ts Error: FatalDiagnosticError: Code: 1010, Message: Input transform function first parameter must have a type
Value could not be determined statically.
et l’input est devenu :
readonly counter = input<number>(0);
mais si vous précisez le type dans la fonction transform
@Input({transform: (value: number) => value * 10})
counter: number = 0;
la migration se passe sans soucis
readonly counter = input<number, number>(0, { transform: (value: number) => value * 10 });
- si vous écrivez les valeurs de l’input au sein du composant, l’utilitaire ne traitera pas l’input.
Avec l’option--insert-todos, vous aurez cependant la raison de pourquoi l’input en question n’a pas été migré :
// TODO: Skipped for migration because:
// Your application code writes to the input. This prevents migration.
@Input()
counter: number = 0;
Pour ces cas-là, vous aviez probablement besoin d’un model plutôt que d’un input et il vous faudra repasser à la main pour choisir le bon type de déclaration pour votre signal.
Migrer les outputs
De la même manière que pour les inputs, une migration est disponible pour les outputs.
Si on l’exécute sur le projet exemple de cet article :
➜ my-app-angular-20 git:(master) ✗ npx @angular/cli@20 generate @angular/core:output-migration
✔ Which directory do you want to migrate? ./
Preparing analysis for: tsconfig.app.json...
Scanning for outputs: tsconfig.app.json...
Preparing analysis for: tsconfig.spec.json...
Scanning for outputs: tsconfig.spec.json...
Processing analysis data between targets...
Successfully migrated to outputs as functions 🎉
-> Migrated 1 out of 1 detected outputs (100.00 %).
UPDATE src/app/counter/counter.component.ts (458 bytes)
Lors de la migration que j’ai effectuée sur une application pour un de nos clients, je n’ai pas rencontré de cas sur lesquels l’utilitaire échoue.
Le seul défaut que j’ai vu est que, si vous avez un EventEmitter<void>, l’utilitaire mettra une TODO superflue dans votre code lors de la migration, au niveau de l’appel à emit :
readonly myOutput = output<void>();
onAction() {
// TODO: The 'emit' function requires a mandatory void argument
this.myOutput.emit();
}
Alors, décorateur ou signal ?
Comme nous l’avons vu à travers plusieurs exemples, l’arrivée des signaux pour le binding de nos composants va beaucoup simplifier nos composants !
Les fonctions input et output permettent de déclarer des inputs et outputs avec la même précision que les décorateurs, de manière plus compacte, et en permettant de bénéficier de l’API des signaux.
La toute nouvelle fonction model adresse le cas du double binding très simplement.
Pour mon équipe, le choix est fait : signal !
Les utilitaires d’Angular sont très complets pour vous accompagner dans une migration sans douleur et qui ne cassera pas le build de votre application.
Il est temps d’adopter le nouveau système, dans votre application Angular !
Vous souhaitez vous approprier les nouveautés des dernières versions d’Angular, comme par exemple les standalone components ou bien les signaux, ou encore migrer vers ces nouveaux concepts sur vos applications ?
Nos expert·e·s peuvent vous accompagner. Découvrez nos formations Angular 👉 https://www.4sh.fr/learning/formations/





Laissez une réponse