Input, output, viewChild... Encore plus de signaux 📡

Input, output, viewChild... Encore plus de signaux 📡

LoĂŻc Boutter (lazybobcat)
LoĂŻc Boutter (lazybobcat)

Dans un précédent article nous avons vu comment utiliser les Signal avec Angular. Dans cet article nous allons découvrir quelles fonctionnalités d’Angular utilisent des signaux.

Vous trouverez un exemple complet avec le code au bas de cet article.

Au sommaire :

  1. input() : en entrée du composant
  2. output() : en sortie du composant
  3. viewChild(), viewChildren()
  4. contentChild(), contentChildren()
  5. model() : liaisons bidirectionnelles
  6. Liens et exemple complet

input() : en entrée du composant

L’une des fonctionnalités les plus courrament utilisées dans Angular est la communication entre un composant “parent” et un composant “enfant” via les entrées “input”. Avec l’introduction des signaux, cette communication devient plus facile.

Auparavant, si l’on avait un signal en entrée d’un composant, il fallait utiliser le décorateur @Input() afin de déclarer son “input” :

@Input() name = 'default name';
@Input() pokemon!: Pokemon;
@Input({ required: true }) pokemon!: Pokemon; // version "required"

Dorénavant nous pouvons utiliser l’équivalent sous forme de signal en utilisant la fonction input() fournie par Angular. Cette fonction prend un paramètre de type et/ou une valeur (si une valeur est passée, le type peut être automatiquement inféré au type de cette valeur).

// name est de type InputSignal<string> et a la valeur "default name" directement
// attribuée au signal :
name = input('default name');
 
// pokemon est de type InputSignal<Pokemon | undefined> car il n'a pas de valeur
// initiale :
pokemon = input<Pokemon>();
 
// pokemon est de type InputSignal<Pokemon> car il est obligatoire :
pokemon = input.required<Pokemon>();

Les signaux ne sont pas beaucoup moins verbeux, alors pourquoi utiliser la syntaxe sous forme de signaux plutôt que les décorateurs ? Il y a plusieurs bonnes raisons :

  1. On a la puissance des signaux de notre côté. Il est très facile de calculer automatiquement des valeurs dérivées avec computed(). L’équivalent sous forme de décorateur serait bien plus verbeux, avec des setters et/ou Subjects RxJs.
  2. Lorsqu’un signal utilisé dans un template change, le composant va automatiquement être marqué comme “dirty” si la stratégie de détection de changement est “OnPush” (et c’est ce que vous utilisez par défaut, pas vrai ? Pas vrai ??).
  3. Les signaux sont type-safe. Dans le cas du required, plus besoin de tricher avec Typescript pour lui assurer qu’une valeur sera définie. On peut également fournir une fonction de transformation au signal pour transformer, par exemple, un “input” de type string en boolean ("yes" -> true) :
alive = input<boolean, string>(false, {
  transform: (flag: string) => 'yes' === flag,
});
  1. C’est un sujet d’article en soi, mais Angular a pour ambition de devenir “zoneless” (comprendre : sans “zone.js”) et l’utilisation des signaux est une pierre angulaire pour y parvenir.

Puisque cet “input” est un signal, il s’utilise comme si on appelait une fonction pour obtenir la valeur qu’il contient :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <section class="poke-info">
    <img [src]="pokemon().image" [alt]="pokemon().name" />
    <div>
      <div><strong>{{ pokemon().name }}</strong></div>
      <div><em>Type: @for (type of pokemon().apiTypes; track type.name) { {{ type.name }} }</em></div>
    </div>
    <!-- ... -->
  </section>
  `,
})
export class PokemonInfoComponent {
    pokemon = input.required<Pokemon>();
}

output() : en sortie du composant

Attention : output() ne renvoie pas du tout un signal.

La fonction output() a été ajoutée, en remplacement du décorateur @Output() pour être cohérente avec le reste des changements de l’API d’Angular suite à l’introduction des signaux. Cette fonction renvoie un EventEitterRef (similaire à un EventEmitter mais sans dépendre de RxJs) et sa valeur peut être changée grâce à la méthode emit() :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <section>
    <button (click)="adopt.emit(pokemon())">Adopter</button>
  </section>
  <section class="poke-info">
    <!-- ... -->
  </section>
  `,
})
export class PokemonInfoComponent {
  // ...
  adopt = output<Pokemon>();
}

Son utilisation dans le composant parent ne change pas :

app.component.ts
<pokemon-info [pokemon]="pokemon" (adopt)="adopt($event)" />

viewChild() et viewChildren()

De la même façon que pour les “input” et les “output”, une alternative aux décorateurs @ViewChild() et @ViewChildren() a été ajoutée.

Avec le décorateur, pour récupérer un unique enfant du composant, nous aurions utilisé un code semblable à :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <section class="poke-info">
    <!-- ... -->
    @if (evolution(); as evolution) {
      <pokemon-info-evolution [pokemon]="evolution" />
    }
    <!-- ... -->
  </section>
  `,
})
export class PokemonInfoComponent implements AfterViewInit {
  // ...
  @ViewChild(PokemonInfoEvolutionComponent)
  infoEvolution: PokemonInfoEvolutionComponent | null = null;
 
  ngAfterViewInit() {
    console.log(this.infoEvolution);
  }
}

Le code équivalent utilisant les signaux est un peu plus concis et peut faire usage d’ effect() :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <section class="poke-info">
    <!-- ... -->
    @if (evolution(); as evolution) {
      <pokemon-info-evolution [pokemon]="evolution" />
    }
    <!-- ... -->
  </section>
  `,
})
export class PokemonInfoComponent {
  // ...
  infoEvolution = viewChild(PokemonInfoEvolutionComponent);
 
  constructor() {
    effect(() => {
      console.log(this.infoEvolution());
    });
  }}

Mais le gros avantage, c’est surtout qu’il n’y a plus besoin d’implémenter AfterViewInit ou de savoir quand le composant enfant sera disponible : en effet puisqu’on utilise un signal, la valeur sera tout simplement émise lorsqu’elle est disponible et nous pouvons commencer à l’utiliser à partir de ce moment là !

Nous pouvons même utiliser viewChild.required(...) si l’on souhaite qu’il y ait obligatoirement une correspondance :

infoEvolution = viewChild.required(PokemonInfoEvolutionComponent);

Les options disponibles sur le décorateurs le sont aussi pour la fonction viewChild(), elles peuvent être passées en second paramètre :

infoEvolution = viewChild(PokemonInfoEvolutionComponent, {
  read: true
});

On peut également utiliser les variables du template (#mavariable) pour identifier un élément. Par exemple si on a <pokemon-info-evolution [pokemon]="evolution" #evolutionComponent /> :

infoEvolution = viewChild('evolutionComponent');

Qu’en est-il de viewChildren() ? Il fonctionne exactement de la même manière, mais vous l’aurez deviné, il récupère tous les éléments correspondant à la requête :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
    <pokemon-info-evolution [pokemon]="evolution1" />
    <pokemon-info-evolution [pokemon]="evolution2" />
    <pokemon-info-evolution [pokemon]="evolution3" />
  `,
})
export class PokemonInfoComponent {
  // ...
  infoEvolutions = viewChildren(PokemonInfoEvolutionComponent);
 
  constructor() {
    effect(() => {
      console.log(this.infoEvolutions());
    });
  }}

contentChild() et contentChildren()

Le principe est exactement le même que pour viewChild() et viewChildren() mais appliqué aux décorateurs @ContentChild() et @ContentChildren().

Afin d’illustrer l’utilisation de viewChildren() avec un exemple, imaginons qu’une liste arbitraire de statistiques d’un Pokémon puisse être passée à un sous-composant pokemon-info-stats qui aura besoin de les lister :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <!-- ... -->
  <pokemon-info-stats>
    <pokemon-info-stat name="HP" [value]="pokemon().stats.HP" />
    <pokemon-info-stat name="Att" [value]="pokemon().stats.attack" />
    <pokemon-info-stat name="DĂ©f" [value]="pokemon().stats.defense" />
    <pokemon-info-stat name="Vit" [value]="pokemon().stats.speed" />
  </pokemon-info-stats>
  `,
})
export class PokemonInfoComponent {
  // ...
}

Le composant PokemonInfoStatsComponent utilise viewChildren() pour récupérer toutes les directives <pokemon-info-stat ...> qui ont été placées dans le contenu du composant (voir ci-dessus) :

pokemon-info-stats.component.ts
@Component({
  selector: 'pokemon-info-stats',
  template: `
  @for (stat of stats(); track stat) {
    <div><strong>{{ stat.name() }}:</strong> {{ stat.value() }}</div>
  }
  `,
})
export class PokemonInfoStatsComponent {
  stats = contentChildren(PokemonStatDirective);
}
pokemon-info-stat.directive.ts
@Directive({
  selector: 'pokemon-info-stat',
})
export class PokemonStatDirective {
  name = input.required<string>();
  value = input.required<string | number>();
}

model() : liaisons bidirectionnelles

Si vous avez travaillé avec des formulaires, vous avez forcément déjà vu ce type de notations :

<input type="text" [(ngModel)]="firstName" />

Il s’agit d’un “two-way binding” ou “double data binding”. Il permet de passer une valeur à la fois en lecture (input) au composant enfant et à la fois à la modifier (en output) lorsque le composant enfant la change. Au passage, pour se souvenir de l’ordre des [] et des () pensez à la banane dans la boîte (“banana in the box”) : [(banana)].

Contrairement aux signaux provenant d’un input(), les signaux renvoyés par model() sont “writable”, on peut changer leur valeur. Heureusement, sinon il serait impossble de faire du two-way binding ! Avec model(), inutile de déclarer un output ...Changed() dans le composant, comme il fallait le faire auparavant.


Prenons un exemple. On souhaite afficher un compteur de like sur le composant racine. Un bouton “like” doit se situer sur la fiche d’info du Pokémon. Lorsqu’on clique sur le bouton “like” le compteur doit être incrémenté. Mais lorsqu’on clique sur “suivant” dans le composant racine, le compteur doit être réinitialisé.

Commençons par définir un model() nommé “likes” dans les informations du Pokémon et y attacher le bouton “like” :

pokemon-info.component.ts
@Component({
  selector: 'pokemon-info',
  template: `
  <section>
    <button (click)="like()">Like ({{ likes() }} likes)</button>
  </section>
  <section class="poke-info">
    <!-- ... -->
  </section>
  `,
})
export class PokemonInfoComponent {
  // ...
  likes = model.required<number>(); 
 
  like() {
    this.likes.update((count) => count + 1);
  }
}

Dans le composant parent, nous affichons le nombre de likes et nous assurons que lorsqu’on change de Pokémon, le compteur est réinitialisé. Nous définissons le compteur sous la forme d’un signal likesCounter :

app.component.ts
@Component({
  selector: 'app-root',
  template: `
  @if (pokemon(); as pkm) {
    <pokemon-info [pokemon]="pkm" [(likes)]="likesCounter" />
    <div>Ce pokémon a {{ likesCounter() }} likes!</div>
    <button (click)="next()">Suivant</button>
  }
  `,
})
export class App {
  // ...
  likesCounter = signal(0);
 
  next() {
    this.likesCounter.set(0);
 
    const nextPokemonId = computeNextPokemon();
    this.currentId.set(nextPokemonId);
  }
}

Et c’est tout ! Lorsqu’on clique sur “like”, le model likes est mis à jour et comme il est associé en lecture (input) et en écriture (output) au signal likesCounter, ce signal est également mis à jour et par conséquent l’affichage dans le template change. Lorsqu’on remet à zéro le signal likesCounter, puisqu’il est associé en input/output au model likes, ce dernier est également remis à zéro !

Liens et exemple complet

Vous pouvez retrouver l’exemple complet sur Stackblitz.

Voici Ă©galement quelques liens vers des sources de documentation et de quoi aller plus loin :

Retour aux articles