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 :
- 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.
- 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 estWritableSignal<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 signalcelsius
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 estSignal<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 :
toSignal()
qui prend un observable et renvoie un signaltoObservable()
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 !