Aller au contenu

Tout comprendre sur les Signals

Des applications Angular plus performantes avec les Signals
 et avec une meilleure DX ?

Les Signals sont arrivĂ©s avec Angular 16 ce qui a mis la communautĂ© Angular en effervescence ! Mais le sujet est complexe, c’est pourquoi j’ai Ă©crit cette article qui compile toutes les infos importantes et qui explique le pourquoi du comment des Signals et comment ils vous aideront dans vos applications Angular.


Ok alors revenons aux bases. Les Signals c’est un sujet complexe, donc on va essayer d’y aller Ă©tape par Ă©tape.

Le problĂšme

Considérons ce composant :

@Component({
  template: `
    <p>{{ celsius }}</p>
    // 25
    <p>{{ fahrenheit }}</p>
    // 77
  `,
})
export class SomeComponent {
  celsius = 25;
  fahrenheit = this.celsius * 1.8 + 32;
}

Ca marche nickel, j’affiche 25 degrĂ©s Celsius et 77 degrĂ©s Fahrenheit. Mais que se passe t-il si j’ajoute un bouton pour doubler celsius ?

@Component({
  template: `
    // je clique sur ce bouton 👇
    <button (click)="doubleCelsius()">Doubler le degré celsius</button>
    <p>{{ celsius }}</p>
    // ça me renvoie bien 50
    <p>{{ fahrenheit }}</p>
    // ça me renvoie toujours 77 đŸ˜±
  `,
})
export class SomeComponent {
  celsius = 25;
  fahrenheit = this.celsius * 1.8 + 32;

  doubleCelsius() {
    this.celsius = this.celsius * 2;
  }
}

Patatra, ça marche plus ! fahrenheit me renvoie toujours 77. Il n’a pas Ă©tĂ© recalculĂ©. Pourquoi ? Et bien cette propriĂ©tĂ© est initialisĂ©e lors de la construction du composant, et seulement Ă  ce moment-lĂ . A aucun moment fahrenheit ne se recalcule, cette propriĂ©tĂ© n’est pas rĂ©active, ça veut dire qu’elle ne rĂ©agit pas (elle ne se recalcule pas) lorsque les valeurs auxquels elle dĂ©pend changent.

Une solution serait de transformer fahrenheit en getter.

@Component({
  template: `
    <button (click)="doubleCelsius()">Doubler le degré celsius</button>
    <p>{{ celsius }}</p>
    <p>{{ fahrenheit }}</p>
  `,
})
export class AppComponent {
  celsius = 25;

  get fahrenheit() {
    return this.celsius * 1.8 + 32;
  }

  doubleCelsius() {
    this.celsius = this.celsius * 2;
  }
}

Et lĂ , ça fonctionne impec’ ! fahrenheit est bien recalculĂ©e quand je clique sur le bouton. Mais comment ça marche et pourquoi ce n’est pas une bonne solution ? En fait, le getter va se recalculer dĂšs que la change detection d’Angular s’exĂ©cute et pas seulement quand celsius change, ce qui est mauvais pour la performance.

Bon
 Quelle est la bonne solution alors ? RxJS to the rescue! Voyons comment on peut facilement solutionner notre problùme avec RxJS.

@Component({
  template: `
    <button (click)="doubleCelsius()">Doubler le degré celsius</button>
    <p>{{ celsius$ | async }}</p>
    <p>{{ fahrenheit$ | async }}</p>
  `,
})
export class AppComponent {
  celsius$$ = new BehaviorSubject(25);
  celsius$ = this.celsius$$.asObservable();
  fahrenheit$ = this.celsius$.pipe(map((celsius) => celsius * 1.8 + 32));

  doubleCelsius() {
    this.celsius$$.next(this.celsius$$.value * 2);
  }
}

Super ! Ca fonctionne trĂšs bien, l’observable fahrenheit$ dĂ©pend de celsius$ , lorsque ce dernier est modifiĂ© (grĂące Ă  this.celsiusSubject$$.next()) alors fahrenheit$ est Ă©galement modifiĂ©. C’est ce que l’on appelle la programmation rĂ©active car fahrenheit$ â€œĂ©coute” ses dĂ©pendances et rĂ©agit lorsque la valeur de celles-ci changent.

Quel est le problĂšme alors ?

On en a deux :

  1. La performance. Sans entrer trop dans les dĂ©tails car ça pourrait ĂȘtre un article Ă  part entiĂšre, la dĂ©tection de changement dans Angular fonctionne trĂšs bien mais n’est pas optimale. Pour gĂ©rer sa change detection Angular se repose sur zone.js, une librairie tierce qui Ă©coute tous les Ă©vĂšnements du browser (clique, mouvements de souris, setTimeout, setInterval
) et permet de faire des choses en callback dĂšs qu’un Ă©vĂšnement se termine. Angular tire partie de cela en lançant sa change detection ce qui met Ă  jour les templates de tous les composants actuellement dans le DOM, et pas seulement sur les composants oĂč il y a bel et bien un changement.
  2. La complexitĂ© du code. Comme vous le voyez dans le code au dessus, on doit apprendre et comprendre RxJS pour construire des applications efficaces. Et rares sont ceux qui maĂźtrisent cette librairie et ses paradigmes. La team Angular a identifiĂ© que beaucoup de devs’ s’éloignent d’Angular Ă  cause de cette complexitĂ©.

Ils ont donc décider de simplifier les choses.

Introducing les Signals

Le principe des Signals n’est pas une invention d’Angular, en fait c’est un paradigme connu depuis des dizaines d’annĂ©es. Et mĂȘme dans l’écosystĂšme frontend on a dĂ©jĂ  des implĂ©mentations des Signals (SolidJS, Vue
). Il Ă©tait donc temps que Angular s’y mette aussi !

Voici l’équivalent de la fonctionnalitĂ© d’avant mais avec les Signals.

@Component({
  template: `
    <button (click)="doubleCelsius()">Doubler le degré celsius</button>
    <p>{{ celsius() }}</p>
    <p>{{ fahrenheit() }}</p>
  `,
})
export class AppComponent {
  celsius = signal(25);
  fahrenheit = computed(() => this.celsius() * 1.8 + 32);

  doubleCelsius() {
    this.celsius.update((celsius) => celsius * 2);
    this.celsius.set(this.celsius() * 2); // on peut aussi faire comme ça, ça revient au mĂȘme
  }
}

DĂ©cortiquons ce qu’il se passe dans ce composant :

  • On crĂ©Ă© notre premier signal avec une valeur par dĂ©faut : celsius = signal(25) . Son type est WritableSignal<number> ce qui signifie que vous pouvez modifier ce signal (il n’est pas readonly).
  • Pour lire la donnĂ©e actuelle de mon signal, je l’exĂ©cute, je le fais Ă  deux endroits : dans le template et dans la fonction computed. C’est donc comme cela qu’on accĂšde Ă  la valeur courante d’un signal : celsius(). Et c’est bien ça tout le principe d’un Signal : c’est un “wrapper” par dessus une valeur. Quand on exĂ©cute la fonction qui wrap la valeur elle nous renvoie la derniĂšre valeur connue du signal.
  • La fonction computed nous permet d’obtenir la rĂ©activitĂ© que l’on dĂ©sire tant ! computed prend une fonction en argument, dans cette fonction on utilise le signal celsius et on renvoie le calcul qui permet de transformer des dĂ©grĂ©s Celsius en Fahrenheit. La magie opĂšre dĂšs lors que celsius change, en fait dĂšs que cela va arriver alors fahrenheit va se recalculer, et tout ça grĂące Ă  computed ! Cette fonctione traque ses dĂ©pendances (les Signals qui sont exĂ©cuter Ă  l’intĂ©rieur) et se rĂ©exĂ©cute dĂšs que l’une d’entre elles changent et est diffĂ©rent de la valeur prĂ©cĂ©dente. A noter que le type de retour d’un computed est Signal<T>, et est readonly !
  • Pour modifier la valeur d’un signal, on peut utiliser .update() qui prend en argument une fonction dont l’argument est la valeur actuelle du signal.
  • Alternativement, on peut utiliser .set() pour modifier la valeur d’un signal si on a pas besoin de sa valeur courante.

Je ne l’ai pas mis dans mon exemple mais nous avons Ă©galement la fonction effect. effect s’exĂ©cute dĂšs qu’un signal utilisĂ© en son sein change de valeur :

@Component({
  template: `
    <button (click)="doubleCelsius()">Doubler le degré celsius</button>
    <p>{{ celsius() }}</p>
    <p>{{ fahrenheit() }}</p>
  `,
})
export class AppComponent {
  celsius = signal(25);
  fahrenheit = computed(() => this.celsius() * 1.8 + 32);

  doubleCelsius() {
    this.celsius.update((celsius) => celsius * 2);
  }

  log = effect((onCleanUp) => {
    console.log(`celsius vient de changer, il vaut ${this.celsius()}`);

    onCleanUp(() => {
      console.log("le composant est détruit");
    });
  });
}

DĂšs que le signal celsius changera, alors l’effect s’exĂ©cutera, car je l’utilise dans le console.log. Et en plus de cela, les effects ont une cleanup function qui s’exĂ©cute au destroy du contexte de lĂ  oĂč il a Ă©tĂ© appelĂ© ! L’utilitĂ© de effect est encore sujet Ă  dĂ©bat, certains les utilisent pour faire leur call HTTP par exemple. Attendons d’avoir un peu de recule pour trouver les meilleures use cases.

Le gain en performance grĂące aux Signals

Je le disais au dĂ©but, l’objectif est notamment d’amĂ©liorer les performances de vos applications Angular. Avec la version 16 nous sommes Ă  mi-chemin de cet objectif car le framework a pour le moment encore besoin de zone.js pour savoir quand exĂ©cuter sa change detection. Mais ça ne sera plus le cas trĂšs prochainement car nous pourrons ajouter un attribut signal: trueĂ  nos composants (un peu comme standalone: true) pour complĂštement se passer de zone.js et exĂ©cuter la change detection directement au niveau des composants qui en ont besoin !

Ainsi, la change detection sera exĂ©cutĂ©e uniquement lorsque la valeur d’un signal utilisĂ©e dans un template changera et le framework procĂšdera Ă  la mise Ă  jour du template uniquement au composant affectĂ© par le changement et non plus Ă  l’arbre entier de composants. C’est une Ă©norme diffĂ©rence ! Nous n’aurons mĂȘme plus besoin de changeDetection: ChangeDetectionStrategy.OnPush !

Bye bye RxJS alors ?

On a vu que nous n’avions plus besoin de Subject ou des Observable pour crĂ©er nos donnĂ©es et les modifier, alors plus besoin de RxJS, si ?

Et bien
 Pas si sûr !

RxJS ce n’est pas que des Observable , c’est aussi des operators. switchMap, filter, tap, debounceTime 
 Tous ces operators sont lĂ  pour nous faciliter la vie et bon courage pour implĂ©menter un switchMap fait maison.

Forte heureusement, la team Angular a pensĂ© Ă  nous ! Ils mettent en avant l’intĂ©ropĂ©rabilitĂ© entre les Signals et RxJS, c’est Ă  dire le fait que les deux “univers” peuvent fonctionner ensemble.

Nous avons donc accÚs à deux méthodes :

  1. toSignal() qui prend un observable et renvoie un signal
  2. toObservable() qui prend un signal et renvoie un observable

Voyons comment on peut utiliser ça :

@Component({
  template: `
    <ul>
      <li *ngFor="let product of availableProducts">
        {{ product.title }}
      </li>
    </ul>
  `,
})
export class AppComponent {
  http = inject(HttpClient);
  // j'utilise httpClient de maniĂšre classique pour
  // faire mon call http et passer mes operators rxjs
  availableProducts$ = this.http.get('api/products')
    .pipe(
      filter(product => product.quantity > 0),
      map(...),
      catchError(...)
    );

  // je convertis mon observable en signal avec toSignal.
  // A noter que toSignal() subscribe et unsubscribe automatiquement !
  availableProducts = toSignal(
    this.availableProducts$,
    {initialValue: []} // sinon ça émet "undefined" au début
  );
}

Ainsi, on peut toujours profiter de la puissance des operators RxJS et des Signals !

Un autre exemple montrant l’utilisation de toObservable :

@Component({
  template: `
    <input type="number" (input)="changeProductId($event)" />
    <p>{{ product().title }}</p>
  `,
})
export class AppComponent {
  http = inject(HttpClient);

  productId = signal(1);
  product$ = toObservable(this.productId).pipe(
    switchMap((productId) => this.http.get(`api/products/${productId}`))
  );

  product = toSignal(this.product$, { initialValue: {} });

  changeProductId(event: Event) {
    const id = (event.target as HTMLInputElement).value;
    this.productId.set(Number(id));
  }
}

On caste en observable le signal qui contient l’id du produit afin d’écouter dessus et dĂšs qu’il change (via l’input) on fait un switchMap() en utilisant l’endpoint avec la valeur de l’id du produit. Enfin notre propriĂ©tĂ© product convertit le rĂ©sultat en signal.

Conclusion

En conclusion, les Signals font partie de la mouvance de la grande renaissance de Angular et vont changer beaucoup de choses. Il faudra attendre que les librairies tierces Ă©mergent et imposent les bonnes pratiques, je pense notamment Ă  NgRx. Aussi, la team Angular va certainement passer aux Signals pour ses fonctionnalitĂ©s genre HttpClient ou les formulaires (rien n’est sĂ»r mais ça va sĂ»rement arriver tĂŽt ou tard). Donc il est important de se mettre Ă  jour sur les Signals et vous pouvez compter sur moi pour vous apporter toutes les infos nĂ©cessaires !