6. Les signaux et la détection de changement

0

Dans les derniers chapitres, nous avons parlé des inputs ainsi que des outputs, qui sont des signaux. Il est temps maintenant d’approfondir le sujet et de voir ce que sont les signaux, et ce qu’est la détection de changements en Angular.

Entre autres, nous allons voir:

  • ce que sont les signaux
  • on verra comment créer nos propres signaux avec la primitive signal()
  • on regardera comment créer des signaux qui dépendent d’autres signaux avec computed()
  • puis on verra comment exécuter du code lorsque des signaux sont changés avec effect()
  • on parlera des différentes strategies de détection de changement
  • et pour finir on expliquera ce qu’est « zone.js » et ce qu’est une application « zoneless »

A la fin de ce chapitre, on aura le résultat suivant: un bouton qui nous permettra de changer le contenu de l’une de nos cartes d’objets à collectionner.

Pour rappel, tout au long de ce cours, on va créer une application de gestion de collections de timbres, pièces, figurines, cartes à jouer ou autre. Dans le dernier chapitre, on a vu comment créer un projet Angular. Donc, on va partir du principe que vous avez un projet prêt à être utilisé.

application de gestion de collections

Les signaux / signals

Commençons par expliquer ce que sont les signals, ou signaux en français. Un signal est une valeur réactive : c’est une variable spéciale qui notifie automatiquement Angular quand elle change.

Concrètement, quand vous modifiez un signal, tous les composants ou les expressions qui en dépendent se mettent à jour automatiquement. Les signaux permettent de créer des propriétés dépendantes d’autres propriétés de manière très simple et ils sont l’avenir d’Angular.

Les signaux ont été introduits dans la version 16 d’angular, avec les trois primitives suivantes:

  • signal() : pour créer une valeur réactive modifiable.
  • computed() : pour créer une valeur dérivée qui dépend d’un ou plusieurs signaux.
  • effect() : pour exécuter du code automatiquement lorsque les signaux utilisés changent.

Ensuite, d’autres fonctions ont été introduites, comme par exemple les inputs() et les outputs() que nous avons vu dans les chapitres précédents.

La primitive signal()

Comme on le disait à l’instant, la primitive signal() qu’on peut importer de « @angular/core », permet de créer un signal modifiable. signal() retourne un objet de type Signal<T>, où T représente le type de la valeur qu’il contient. Un signal contient une valeur, qu’on peut lire et mettre à jour. La différence entre une variable et un signal, est que le signal notifie automatiquement Angular lorsque sa valeur change. Angular peut ainsi, lancer la ré-évaluation des signaux qui en dépendent ou encore la mise à jour de l’interface graphique.

Créer un signal

Regardons comment créer un signal, pour cela on va modifier le fichier « app.ts » de notre application de gestion de collections afin de stocker nos deux objets de collection dans un tableau qu’on va appeler collectionItems. En plus de cela on va créer un nouvel attribut qui va contenir l’index de l’objet de collection qu’on souhaite afficher:

TypeScript
import { Component, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

}

Comme vous le voyez pour créer un signal il suffit d’assigner le retour de signal() à notre attribut, et on passe la valeur initiale de notre signal en paramètre à celui-ci. Donc ici on a créé un signal avec la valeur initiale à 0.

Accéder à un signal

Maintenant si on souhaite accéder à ce signal il suffit de l’appeler en utilisant des parenthèses. On peut donc modifier notre fichier « app.html » comme ceci :

HTML
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (submit)="increaseCount()"
            [(search)]="search"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="collectionItems[selectedItemIndex()]">
    </app-collection-item-card>
</section>

Maintenant si on lance le projet et qu’on regarde le navigateur on voit que l’objet à l’index zéro est affiché :

La méthode set

Regardons comment modifier ce signal. Les signaux on deux méthodes qui nous permettent de les modifier, la première méthode, est la méthode set, qui nous permet de lui assigner une nouvelle valeur. Ajoutons un bouton au fichier « app.html », qu’on va appeler « Toggle » et qui lorsqu’on le click va modifier l’index de l’objet à afficher :

HTML
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (submit)="increaseCount()"
            [(search)]="search"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="collectionItems[selectedItemIndex()]"></app-collection-item-card>
</section>
<button (click)="toggleItem()">Toggle item</button>

Nous pouvons maintenant implémenter la méthode toggleItem comme suit:

TypeScript
import { Component, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

  toggleItem() {
    const currentIndex = this.selectedItemIndex();
    this.selectedItemIndex.set((currentIndex + 1) % 2);
  }

}

Ici on lit tout d’abord la valeur de notre signal selectedItemIndex en y faisant appel en utilisant les parenthèses, puis on écrit une nouvelle valeur dans notre signal en utilisant la méthode set de celui-ci. La valeur qu’on assigne au signal est la valeur actuelle à laquelle on ajoute 1, puis on fait un modulo 2 du tout. Donc, si la valeur est à 0, on assigne 1 au signal, et si la valeur est à 1, on lui assigne 2 module 2, donc 0.

Et la si on teste notre code, on voit que le button toggle fonctionne et que l’objet affiché alterne entre les deux objets présents dans notre liste :

La méthode update

Quand la nouvelle valeur que nous souhaitons assigner à un signal dépend de la valeur précédente, on peut utiliser la méthode update, pour mettre à jour le signal. La méthode update prend une fonction en paramètre. Cette fonction va recevoir la valeur actuelle du signal en input et doit retourner la nouvelle valeur à assigner au signal en tant que résultat. Dans notre cas on peut donc modifier le code de la manière suivante :

TypeScript
import { Component, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

  toggleItem() {
    this.selectedItemIndex.update(currentIndex => (currentIndex + 1) % 2);
  }

}

Et la aussi, si on regarde notre navigateur on a le même résultat qu’avant :

La primitive computed()

Dans notre exemple, nous affichons un élément précis de notre liste d’objets, et cet élément dépend de notre signal selectedItemIndex. Ce serait bien de pouvoir créer un variable qui contienne l’élément a afficher afin qu’on puisse le réutiliser dans notre code sans devoir à chaque fois accéder à notre liste.

La primitive computed() permet justement de définir un nouveau signal qui dépend d’un signal existant. Pour cela on passe une fonction en paramètre à notre computed(), qui va retourner la valeur qu’on souhaite stocker dans notre signal. La primitive computed() a la particularité qu’elle va exécuter cette fonction, à chaque fois que l’un des signaux utilisés dans celle-ci est modifié. Dans notre exemple, si on souhaite créer un attribut computed(), nommé selectedItem, on peut faire la chose suivante :

TypeScript
import { Component, computed, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  selectedItem = computed(() => this.collectionItems[this.selectedItemIndex()]);
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

  toggleItem() {
    this.selectedItemIndex.update(currentIndex => (currentIndex + 1) % 2);
  }

}

Maintenant nous pouvons utiliser ce nouveau signal dans le fichier »app.html », de la même manière qu’un signal standard :

HTML
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (submit)="increaseCount()"
            [(search)]="search"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="selectedItem()"></app-collection-item-card>
</section>
<button (click)="toggleItem()">Toggle item</button>

Si on retourne voir le résultat dans le navigateur, on voit que tout fonctionne comme avant :

Notre nouveau signal computed() est recalculé à chaque fois que le signal selectedItemIndex est modifié.

Notez qu’un computed() est un signal en lecture seule. Vous ne pouvez pas lui assigner de valeurs avec les méthodes set ou update.

Les input() et les computed() sont des signaux, et vous pouvez donc les utiliser à l’intérieur d’autres signaux computed(). A chaque changement de ses input() ou computed() le signal computed() qui les utilise sera ré-évalué.

La primitive effect()

La troisième primitive de laquelle on va parler, est la primitive effect(). Cette primitive prend une fonction en paramètre et est ré-exécuté à chaque fois que les signaux utilisés dans celle-ci changent. Il y à deux use cases principaux pour effect():

  • logger les changement de valeurs des signaux
  • synchroniser la valeur de signaux avec le localStorage

Il existe encore d’autres cas très spécifiques ou effect() peut être utile, mais en règle général il faut éviter d’utiliser les effect() pour autre chose que pour les deux cas d’usages mentionnés.

Les effects peuvent être défini dans le constructeur d’un composant ou an tant qu’attribut de celui-ci. Nous pouvons donc logger les changements de nos signaux de la manière suivante :

TypeScript
import { Component, computed, effect, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  selectedItem = computed(() => this.collectionItems[this.selectedItemIndex()]);

  loggingEffect = effect(() => {
    console.log(this.selectedItemIndex(), this.selectedItem());
  })
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

  toggleItem() {
    this.selectedItemIndex.update(currentIndex => (currentIndex + 1) % 2);
  }

}

Si on regarde notre navigateur. on voit que l’effect est exécuté directement lors de la création de notre composant et ensuite on a un nouveau print à chaque fois qu’on appuie sur le bouton « Toggle ».

Attention: Ne changer jamais le contenu d’un signal dans un effect(). Le faire pourrait entrainer des erreurs, des détection de changements inutiles et même des boucles infinies.

Les strategies de détection de changements (Change detection strategies)

Parlons maintenant de détection de changements en Angular. Par quel miracle Angular sait-il qu’il faut adapter notre objet de collection quand on clique sur le bouton « Toggle » ?

Et bien aujourd’hui il existe deux manière, qui permettent à Angular d’être notifié d’événements qui pourrait impacter l’état de l’application. La première méthode est basée sur la librairie « zone.js » et la deuxième méthode ne nécessite pas cette librairie, et est appelée « zoneless ». Pour expliquer tout cela, on va tout d’abord regarder comment fonctionne la détection de changement avec « zone.js » et nous verrons ensuite ce qui change quand on passe en « zoneless ».

Détection de changement avec « zone.js »

Jusqu’à la version 20.2 d’Angular, la détection de changement se reposait sur la librairie « zone.js », qui fonctionne de la manière suivante:

A chaque fois que l’utilisateur interagit avec une application utilisant « zone.js », via des inputs, des boutons ou autre, ou alors quand des résultats d’appels asynchrones sont reçus (par exemple, les résultats d’un appel API), « zone.js » intercepte ces événements et en informer Angular.

Stratégie par défaut

A ce stade, dans sa stratégie par défaut, Angular va partir du principe que tout événement notifié par « zone.js » peut avoir un impact sur n’importe quel composant affiché à l’écran, et Angular va donc vérifier pour chaque composant s’il doit le mettre à jour ou non. Pour cela, Angular va commencer par le composant « root » et va ensuite parcourir un à un chaque composant de l’arbre de composants.

Angular default change detection

C’est pour cela que lorsqu’on clique sur notre bouton « Toggle », qui à priori est indépendant de notre objet de collection, Angular vérifie tout de même le composant lié à notre objet et applique immédiatement le changement à l’écran.

Ce comportement est super, étant donné que nous pouvons modifier nos valeurs n’importe où dans notre code, tout en étant sûr que le rendu de chaque composant qui en dépend sera bien mis à jour. En revanche, au niveau des performances, on pourrait mieux faire, car on vérifie tous les composants à chaque modification, même pour des modifications qui n’impactent qu’un nombre limité de composants.

Stratégie OnPush

Ça tombe bien, Angular propose une autre stratégie qui permet d’améliorer ce comportement de détection de changement et qui s’appelle la stratégie OnPush. OnPush doit être activé explicitement pour chaque composant où vous souhaitez l’utiliser et permet d’indiquer à Angular que le composant ne souhaite pas être mis à jour, sauf lorsque celui-ci est marqué pour validation. Ce marquage est uniquement fait sous certaines conditions, qui sont les suivantes:

  • La valeur d’un signal (input(), signal(), …) a changé. Attention: Angular compare les valeurs par référence.
  • Un événement a eu lieu à l’intérieur du composant, comme, p. ex. un clic ou autre
  • Un « pipe async » a reçu une nouvelle valeur (si vous ne savez pas encore ce qu’est un « pipe async », ne vous en faites pas, on en reparlera en temps voulu)
  • Ou alors le composant a été marqué manuellement pour vérification

Il est important de noter que, dans un composant OnPush, les modifications locales de propriétés effectuées à l’intérieur de setTimeout, setInterval, d’une Promise ou d’un Observable ne marquent pas automatiquement le composant pour vérification.

Imaginons un arbre de composant avec deux composants enfant qu’on va appeler « B » et « C ». Le composant « B » utilisant la stratégie par défaut, et le composant « C » utilisant la stratégie OnPush, avec chacun de ces composants ayant à leur tour deux composants fils avec chacun la stratégie OnPush.

Components with OnPush

Si un événement arrive dans le composant avec la stratégie par défaut, et que cet événement n’impacte aucun autre composant, il n’y aura que le composant « root » et le composant avec la stratégie par défaut qui seront vérifiés lors du cycle de détection de changements. Tous les autres composants avec la stratégie OnPush seront ignorés dans ce cas.

OnPush, event occurs on component with default strategy

Maintenant si nous considérons qu’un événement « click » est effectué sur le composant « C » et que cet événement change un input de l’un des composant fils du composant « C ». Dans ce cas, le composant « B », qui utilise la stratégie par défaut, sera vérifié, malgré qu’il ne soit concerné par aucun changement. Le composant « C » sera vérifié, car l’événement « click » y a eu lieu, et pour finir, le composant fils dont l’input a changé sera lui aussi vérifié. Tous les autres composants seront ignorés, car ils n’ont pas été marqués pour vérification.

Event happens in component with OnPush strategy

Détection de changement « zoneless »

Avec le mode « zoneless », Angular n’utilise plus « zone.js », et n’est donc plus notifié par cette librairie des changements qui pourraient nécessiter une mise-à-jour des composants. Cela signifie qu’Angular doit se reposer entièrement sur les notifications explicites que nous lui fournissons pour savoir quand rafraîchir l’affichage.

Ces notifications sont déclenchées par :

  • les signals : quand on change la valeur d’un signal
  • les AsyncPipe : lorsqu’on affiche une donnée asynchrone via AsyncPipe (si vous ne connaissez pas les AsyncPipe ne vous en faite pas, on en reparlera)
  • markForCheck() : on peut appeler cette méthode pour indiquer à Angular qu’un composant a changé et doit être vérifié.
  • les inputs des composants : si une valeur reçue par un composant via un input change
  • les événements du template : les interactions comme des clics ou des saisies

À partir d’Angular 20.2, le mode “zoneless” est stable et peut être utilisé. Pour des projets qui reposent sur les signaux, comme le nôtre, il peut être activé sans aucun changement au code.

Remarque : si vous avez suivi ce cours depuis le début et créé votre projet comme indiqué dans les premiers chapitres, celui-ci fonctionne déjà en mode “zoneless”.

Notez que la stratégie de détection recommandée pour les applications « zoneless » est la stratégie OnPush.

Comme notre projet est un projet « zoneless », je propose de changer la stratégie de tous nos composants en OnPush. Pour cela il suffit d’importer ChangeDetectionStrategy d' »@angular/core » et d’ajouter à tous nos @Component le paramètre changeDetection avec la valeur ChangeDetectionStrategy.OnPush.

Commençons par le fichier « app.ts »:

TypeScript
import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;

  collectionItems: CollectionItem[] = [];
  selectedItemIndex = signal(0);
  selectedItem = computed(() => this.collectionItems[this.selectedItemIndex()]);

  loggingEffect = effect(() => {
    console.log(this.selectedItemIndex(), this.selectedItem());
  })
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();

    this.collectionItems = [
      this.coin,
      this.linx
    ]
  }

  increaseCount() {
    this.count++;
  }

  toggleItem() {
    this.selectedItemIndex.update(currentIndex => (currentIndex + 1) % 2);
  }

}

Adaptons notre « search-bar.ts » :

TypeScript
import { ChangeDetectionStrategy, Component, model, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchBar {

  search = model("Initial");
  searchButtonClicked: OutputEmitterRef<void> = output<void>({
    alias: 'submit'
  });

  searchClick() {
    this.searchButtonClicked.emit();
  }

}

Et pour finir adaptons aussi notre fichier « collection-item-card.ts » :

TypeScript
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CollectionItem } from '../../models/collection-item';

@Component({
  selector: 'app-collection-item-card',
  imports: [],
  templateUrl: './collection-item-card.html',
  styleUrl: './collection-item-card.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionItemCard {

  item = input.required<CollectionItem>();

}

Et comme tout notre code repose sur les signaux, si on retourne dans le navigateur, on voit que la détection de changement fonctionne toujours :

Et voilà on a fait le tour de la détection de changement en Angular. Dans le prochain chapitre on va voir comment créer des boucles et des conditions dans nos templates HTML.

Laisser un commentaire