Cours Angular · Module 6: Signals
Les méthodes d'un signal - les ordres de la tour
Dans cette leçon7
Au relais précédent tu as allumé ton premier feu de fumée. Tu as appelé signal(), tu lui as donné une valeur initiale et tu l'as relue avec une paire de parenthèses. La tour est debout, la fumée monte, le château voisin regarde. Le problème, c'est qu'une tour capable seulement de s'allumer ne sert plus à personne au bout de la première minute d'observation. La vraie valeur d'une tour commence là où le guetteur doit changer quelque chose dans la fumée.
Reprends un instant le composant des statistiques du samouraï de la leçon précédente. Deux appels y sont passés sans que personne te les explique : this.experience.update(xp => xp + amount) au moment de gagner des points, et this.experience.set(0) au moment de monter de niveau. Deux noms différents, deux ordres différents - et ils ne sont pas interchangeables. Il y avait aussi un champ xpToNextLevel que personne n'a jamais rempli et qui affichait pourtant toujours le bon nombre. Trois énigmes, une seule leçon.
Maître Ango-san l'explique ainsi. Sur les murailles de l'Académie s'aligne une rangée de tours, et au guetteur posté devant chacune d'elles le commandant ne peut envoyer que trois sortes d'ordres. Le premier dit "allume exactement trois colonnes de fumée", et le guetteur n'a même pas besoin de savoir combien il en brûlait l'instant d'avant. Le deuxième dit "ajoute une colonne à ce que l'on voit déjà" - là, le guetteur doit d'abord regarder sa propre tour. Le troisième ordre ne s'adresse pas au guetteur mais au scribe assis dans la tour de commandement : "additionne les fumées de la première et de la deuxième tour, et inscris le résultat sur ton ardoise". Le scribe n'allume rien, et son ardoise n'est jamais périmée. Dans le code, ces trois ordres s'appellent set(), update() et computed(), et à la fin de cette leçon tu sauras donner chacun d'eux.
Premier ordre : fixer la valeur
Avant de donner le moindre ordre, il te faut une tour. La fonction signal() s'importe depuis le paquet @angular/core, tu lui passes une valeur initiale et tu récupères un signal dans lequel il est permis d'écrire - dans la documentation d'Angular son type s'appelle WritableSignal, littéralement "signal inscriptible". La lecture, elle, ressemble exactement à ce que tu connais déjà : le nom suivi de parenthèses vides, parce qu'un signal est une fonction.
1import { signal } from '@angular/core';
2
3const count = signal(0);
4
5console.log(count()); // 0Une tour est née avec un zéro à son sommet, et c'est tout ce qui s'est passé pour l'instant. Autant savoir tout de suite ce que la lecture ne fait pas : elle ne modifie pas la valeur, elle n'en fabrique pas de copie et elle ne consomme rien. Tu peux appeler count() cent fois de suite, tu obtiendras cent fois le même nombre. Remarque aussi le type que TypeScript a déduit tout seul de la valeur initiale. signal(0) est un signal de nombre, si bien qu'essayer d'y écrire une chaîne de caractères se solde par une erreur de compilation, avant même que l'application ne démarre. C'est la même discipline de types que celle rencontrée dans le premier module autour du code Bushido, simplement rangée derrière un seul appel de fonction.
Passons au premier ordre. La méthode set() prend un seul argument - la nouvelle valeur, toute prête - et la met à la place de l'ancienne. Le contenu précédent du signal n'a ici aucune importance, personne ne va même le lire. C'est un ordre sans appel : "il en faudra dix, fin de la discussion".
1count.set(10);
2
3console.log(count()); // 10La tour affiche dix. Le plus important reste pourtant ce que cet ordre n'a pas touché : count est toujours le même signal, la même fonction, la même entité en mémoire. Seule la valeur logée à l'intérieur a été remplacée, si bien que tout endroit de l'application qui garde une référence vers count verra le nouveau nombre immédiatement, sans avoir à aller le réclamer. Tu n'as pas non plus eu à prévenir qui que ce soit du changement - Angular a noté l'écriture tout seul et réveillera tout seul ceux qui se servent de cette tour.
Deuxième ordre : recalculer à partir de la valeur précédente
Il existe pourtant des ordres qu'on ne peut pas donner sans regarder la tour. "Ajoute une colonne de fumée" ne désigne aucun nombre précis tant que tu ignores combien il en brûle déjà. On peut certes contourner le problème en écrivant count.set(count() + 1), autrement dit "lis, ajoute, fixe" - mais cela fait trois gestes collés sur une seule ligne, et l'intention se perd au milieu des parenthèses.
C'est pour ces situations qu'existe update(). Cette méthode ne reçoit pas une valeur, mais une fonction. Angular l'appellera lui-même en lui passant en argument le contenu actuel du signal, et ce que ta fonction renverra deviendra la nouvelle valeur. Le nom du paramètre t'appartient : ce peut être v, current, xp ou lvl, le compilateur n'a aucune exigence là-dessus.
1count.update(v => v + 1);
2
3console.log(count()); // 11Dix est devenu onze, alors que le nombre onze n'apparaît nulle part. Lis cet appel de gauche à droite, car l'ordre des morceaux est toujours le même : d'abord count.update(, puis le nom du paramètre v, puis la flèche =>, puis l'expression v + 1 qui fabrique la nouvelle valeur, et enfin la parenthèse fermante ). En revanche, rien n'a changé en dehors du nombre lui-même : le signal est le même qu'il y a un instant, et la fonction que tu as fournie a été appelée exactement une fois et ne resservira jamais.
Puisque tu connais désormais les deux ordres, fixons une bonne fois pour toutes en quoi ils diffèrent, car trois légendes circulent à ce sujet. La différence est unique et porte sur la provenance de la nouvelle valeur : set() la reçoit toute faite de ta part, update() la calcule à partir de la précédente au moyen de la fonction que tu lui as donnée. Ce n'est pas une question de rapidité - les deux méthodes se terminent exactement de la même façon, par l'écriture d'une valeur et la notification des dépendants, si bien que la légende d'un set() rapide et d'un update() lent ne repose sur rien. Ce n'est pas non plus un partage selon le type de données : les deux méthodes fonctionnent indifféremment avec des nombres, des chaînes, des objets et des tableaux, et la phrase "set() pour les objets, update() pour les primitives" est inventée de bout en bout. Et ce ne sont surtout pas deux alias de la même méthode : si c'en était, elles prendraient le même argument, alors qu'elles en prennent deux tout à fait différents, une valeur d'un côté, une fonction de l'autre. Mon conseil : partout où la nouvelle valeur découle de l'ancienne, choisis update(). L'écriture count.update(v => v + 1) dit franchement "augmente de un", tandis que count.set(count() + 1) oblige le lecteur à le déduire de trois étapes distinctes.
Une tour qui n'a encore rien vu
Tous les signaux ne peuvent pas être remplis d'emblée d'une valeur sensée. L'éclaireur vient de partir vers les montagnes et, pendant les premières heures, il n'y a tout simplement aucun rapport - alors que le composant, lui, doit déjà exister et afficher quelque chose. Il te faut donc un signal capable d'être vide.
Mettons-nous d'abord d'accord sur ce qu'est un samouraï dans notre code. C'est un objet à trois champs : name de type string, level de type number et skills, un tableau de chaînes contenant les arts martiaux maîtrisés. Une forme pareille se décrit avec une interface - la construction de TypeScript rencontrée dans le premier module. Nous l'appellerons Samurai et elle nous servira jusqu'à la fin de la leçon. La valeur initiale du signal sera null, c'est-à-dire "on ne sait encore rien", et le type autorisé s'écrira Samurai | null, la barre verticale se lisant "ou bien".
1interface Samurai {
2 name: string;
3 level: number;
4 skills: string[];
5}
6
7const scout = signal<Samurai | null>(null);Les chevrons placés après le nom signal forment un paramètre de type, et cette fois ils sont indispensables. Sans eux, TypeScript déduirait de la valeur initiale qu'il s'agit d'un signal de null tout court, et rejetterait ensuite toute tentative d'y déposer un samouraï. À ce détail près, rien ne change : scout.set(...) et scout.update(...) s'utilisent exactement comme avec le compteur, et la lecture scout() renvoie une valeur de type Samurai | null, si bien qu'avant d'aller chercher le champ name il faut d'abord vérifier que le rapport est bien arrivé.
L'ordre interdit : une tour que l'on ne fait que regarder
Imagine maintenant qu'un seul guetteur s'occupe de la tour, mais que la moitié du château la contemple. Les hérauts sont là pour lire la fumée et porter la nouvelle, pas pour ajouter leurs propres colonnes. Cette disposition revient sans arrêt dans le code : un service ou un composant garde un signal chez lui, le modifie dans ses propres méthodes, et veut le mettre à disposition des autres sous une forme que personne, de l'extérieur, ne puisse déplacer en douce.
C'est le rôle de la méthode asReadonly(). Tu l'appelles sans le moindre argument et tu reçois en échange un signal de type Signal, et non WritableSignal - autrement dit un signal sur lequel il n'y a plus ni set() ni update().
1const readonlyCount = count.asReadonly();
2
3console.log(readonlyCount()); // 11La lecture rend le même onze qu'à l'instant, car il ne s'agit ni d'une copie ni d'un instantané figé. C'est une fenêtre qui donne sur la même tour. Cela se voit le mieux quand tu modifies l'original : la version en lecture seule affiche aussitôt la nouvelle valeur, alors qu'elle-même n'a aucun moyen de la changer.
1count.set(99);
2
3console.log(readonlyCount()); // 99
4
5// readonlyCount.set(0); <- erreur de compilation : cette methode n'existe pasJ'ai vérifié le tout sur un signal en fonctionnement : après l'écriture dans count, la lecture par readonlyCount renvoie quatre-vingt-dix-neuf, et les propriétés set et update sont tout bonnement absentes de cet objet - elles valent undefined. Et rien n'a changé au passage, ni dans la valeur ni dans le signal d'origine : count reste inscriptible, car le droit de donner des ordres n'est retiré qu'au nom que tu distribues à l'extérieur.
Trois autres noms sonnent tout aussi crédibles, n'existent pas, et il vaut la peine de savoir pourquoi. La méthode freeze() ne figure pas dans l'interface d'un signal - il existe bien Object.freeze en JavaScript pur, mais c'est une tout autre affaire : cette fonction gèle les propriétés d'un objet ordinaire et ne connaît strictement rien à la réactivité d'Angular. La méthode lock() n'existe pas davantage, même si le nom appelle presque le geste. La troisième est la plus perfide : const(). Le mot const existe pour de bon en JavaScript, sauf qu'il y est un mot-clé de déclaration et non une méthode. Remarque d'ailleurs que ton compteur est né dès le départ sous la forme const count = signal(0) - et que, malgré ce const, tu viens d'appeler set(99) dessus sans le moindre obstacle. const ne protège en effet que le lien entre le nom et le signal, jamais la valeur que le signal garde à l'intérieur.
Objets et tableaux : un nouveau rouleau plutôt qu'une rature
Jusqu'ici la tour gardait un nombre. Bien plus souvent, elle garde un objet entier - la fiche d'un samouraï, les réglages d'un joueur, une liste de commandes. Servons-nous de l'interface Samurai sur laquelle nous nous sommes mis d'accord plus haut, et bâtissons un signal avec un vrai guerrier à l'intérieur.
1const samurai = signal<Samurai>({
2 name: 'Musashi',
3 level: 1,
4 skills: ['Kendo']
5});Voilà Musashi au premier niveau, avec un seul art martial maîtrisé. Rien de nouveau ne s'est produit ici - c'est toujours le même signal() que pour le compteur, avec un objet au lieu d'un nombre. Les ennuis ne commencent qu'à la première tentative de promotion, car le réflexe souffle une solution qui a l'air innocente et qui est complètement fausse.
1// A NE PAS FAIRE - le signal ne s'en apercevra pas
2samurai().level = 2;J'ai essayé sur un signal en fonctionnement et le résultat est pire qu'on ne pourrait le croire. Le champ change réellement - la lecture samurai().level renvoie deux - mais aucun dépendant n'en sait rien. Une valeur dérivée bâtie sur ce signal et déjà lue une fois auparavant, ce qui est le cas dès qu'un template l'affiche, continuait de renvoyer l'ancien nombre et ne s'est pas recalculée une seule fois. La raison est simple : pour savoir si un signal détient une nouvelle valeur, Angular la compare à l'ancienne par identité, et toi tu as remplacé un champ à l'intérieur de l'objet sans fournir d'objet neuf. Pour le signal, c'est toujours la même entité à la même adresse, il n'y a donc rien à annoncer. C'est le pire genre d'erreur qui soit, car l'application ne lève aucune exception - l'écran se contente d'afficher des données périmées.
Oublie donc cette ligne malheureuse et reprenons Musashi tel qu'il était juste avant elle, au premier niveau. À l'Académie, on appellerait la bonne solution "recopier le rouleau". Tu ne ratures pas une ligne du vieux document, tu en rédiges un neuf en y reportant tout ce qui ne bouge pas et en y inscrivant à ta façon le seul champ concerné. En JavaScript, c'est l'affaire de l'opérateur de décomposition, qui s'écrit avec trois points : ...s verse dans le nouvel objet tous les champs de l'ancien. Un champ mentionné après lui écrase ce qui venait de la copie.
1samurai.update(s => ({
2 ...s,
3 level: s.level + 1
4}));Le samouraï est au deuxième niveau, et cette fois le signal est au courant de la promotion, puisqu'il a reçu un objet entièrement neuf. Fais attention aux parenthèses rondes autour de l'accolade : sans elles, JavaScript prendrait le { pour le début du corps de la fonction et non pour un littéral d'objet, et l'ensemble n'aurait aucun sens. Le plus important reste pourtant ce qui n'a pas bougé : personne n'a recopié à la main les champs name et skills, ils sont arrivés tout seuls par les trois points, et l'objet précédent existe toujours et porte toujours le niveau un - je l'ai vérifié en gardant une référence vers lui avant la mise à jour. La nouvelle valeur ne détruit pas l'ancienne, elle vient se placer à côté d'elle.
La même règle vaut pour les tableaux, à ceci près que les trois points passent à l'intérieur des crochets. L'écriture [...s.skills, 'Iaido'] fabrique un tableau neuf : d'abord toutes les compétences existantes, puis celle que l'on ajoute. Le bloc ci-dessous compte quatre lignes que tu verras dans l'éditeur l'une sous l'autre, et toujours dans cet ordre - ouverture de la mise à jour, copie des champs, nouveau contenu du tableau, fermeture.
1samurai.update(s => ({
2 ...s,
3 skills: [...s.skills, 'Iaido']
4}));Musashi possède désormais deux arts martiaux : le Kendo et l'Iaido. Ni name ni level n'ont changé au passage - les deux valeurs sont passées par ...s sans être touchées, et l'ancien tableau ne contenant que le Kendo repose toujours intact dans l'objet précédent. Le réflexe voudrait qu'on écrive ici s.skills.push('Iaido'), et c'est exactement le même piège qu'il y a un instant : push ajoute sur place, dans le tableau existant, donc l'objet reste le même et le signal, de nouveau, n'annoncera rien à personne.
Le scribe et son ardoise : computed()
Reste l'ordre le plus intéressant, celui que l'on donne au scribe. Imagine un décompte tout simple : un prix unitaire, un nombre d'articles, un taux de taxe et une somme à payer. Le montant hors taxe, la taxe et le total se déduisent des trois premiers nombres, si bien que les garder dans des signaux inscriptibles séparés reviendrait à chercher les ennuis - après chaque changement de prix, il te faudrait penser à trois appels de set() dans le bon ordre. Un jour tu en oublieras un, et l'ardoise accrochée au mur se mettra à mentir.
La fonction computed() s'importe depuis ce même paquet @angular/core. Tu lui passes une fonction sans argument et elle te rend un signal dont tu ne fixes jamais toi-même la valeur : c'est Angular qui la calcule en appelant ta fonction. Au passage, il retient quels signaux ont été lus à l'intérieur et les traite comme des dépendances. Commençons par les trois tours ordinaires dans lesquelles le scribe ira lire.
1import { computed } from '@angular/core';
2
3const price = signal(100);
4const quantity = signal(2);
5const taxRate = signal(0.23);Trois signaux inscriptibles et rien de plus - aucun d'eux n'observe qui que ce soit, chacun se contente de garder son nombre. Toute la comptabilité est encore à venir, et elle tiendra en trois étapes, car une valeur dérivée a parfaitement le droit d'être bâtie sur d'autres valeurs dérivées. Le sous-total subtotal naît du prix et de la quantité. La taxe tax se calcule à partir du sous-total et du taux. Le total total, enfin, c'est le sous-total augmenté de la taxe. Écris-les précisément dans cet ordre - d'abord celui dont les autres se servent - car c'est ainsi qu'ils se lisent comme une phrase et qu'aucune référence ne pointera vers une constante qui n'existe pas encore.
1const subtotal = computed(() => price() * quantity());
2const tax = computed(() => subtotal() * taxRate());
3const total = computed(() => subtotal() + tax());
4
5console.log(total()); // 246J'ai exécuté ce code et il a affiché deux cent quarante-six. Le calcul est limpide : cent fois deux font deux cents, deux cents fois vingt-trois centièmes font quarante-six, et deux cents plus quarante-six font deux cent quarante-six. À l'intérieur de chacune de ces fonctions, tu lis les signaux comme partout ailleurs, avec des parenthèses - price(), quantity(), subtotal(). Remarque au passage que tu n'as appelé nulle part subtotal.set(...) : sur une valeur dérivée, une telle méthode n'existe pas et il est inutile de la chercher.
Et maintenant, le plus intéressant. Change une seule et unique chose, le prix, puis relis le total.
1price.set(200);
2
3console.log(total()); // 492L'affichage donne quatre cent quatre-vingt-douze, et c'est un nombre bien réel, vérifié sur du code qui tourne : deux cents fois deux font quatre cents, quatre cents fois le taux font quatre-vingt-douze, soit quatre cent quatre-vingt-douze en tout. Fais surtout attention à ce que tu n'as pas fait, c'est la phrase la plus importante de cette leçon. Tu n'as réécrit aucune des trois définitions. Tu n'as appelé aucun "recalcule". Tu n'as fixé subtotal, tax ni total en aucun endroit du programme. Tu as changé une tour, et le scribe a corrigé son ardoise de lui-même, parce qu'il se souvenait de ce à partir de quoi il la remplit. C'est précisément ainsi que fonctionnait xpToNextLevel dans le composant de la leçon précédente.
Établissons donc avec précision ce qu'est une valeur dérivée, car trois autres phrases se font passer pour cette définition. Oui, computed() renvoie un signal en lecture seule - sauf que "en lecture seule" n'explique rien du tout, puisque asReadonly() renvoie lui aussi un signal en lecture seule sans jamais rien recalculer. Oui, une valeur dérivée est efficace : je l'ai mesuré, la fonction ne s'exécute pas une seule fois tant que personne ne lit le résultat, trois lectures d'affilée ne la déclenchent qu'une fois parce qu'Angular retient le dernier résultat, et écrire dans la source une valeur identique à l'ancienne ne réveille aucun dépendant. Mais c'est là un effet secondaire, pas une définition - "un signal optimisé pour la performance" décrit tout et rien. Et il n'est pas non plus question de mathématiques : une valeur dérivée renvoie tout aussi bien une chaîne, une condition ou une liste filtrée.
1const battleReady = computed(() => samurai().skills.length >= 2);
2const label = computed(() => samurai().name + ' (niveau ' + samurai().level + ')');La première renvoie vrai, puisque Musashi maîtrise deux arts martiaux, la seconde la chaîne "Musashi (niveau 2)". J'ai vérifié les deux sur du code en fonctionnement. Aucune ne calcule quoi que ce soit de mathématique, et toutes deux sont des valeurs dérivées à part entière, car la définition est unique : c'est un signal dont Angular recalcule automatiquement la valeur dès que l'une quelconque de ses dépendances change. S'il te fallait le prix doublé, computed(() => price() * 2) suffirait - la même formule, cinq morceaux dans un ordre immuable : le nom computed, la parenthèse ouvrante, la flèche sans argument, l'expression, la parenthèse fermante.
Le scribe dans un composant
Tous les exemples précédents étaient de simples constantes au niveau du fichier, afin que la mécanique reste visible sans le moindre échafaudage autour. Dans une vraie application, ces mêmes signaux sont des champs de la classe d'un composant, tu les lis donc à travers this - et, point important, également à l'intérieur de la fonction confiée à computed(), car le même objet y règne. C'est la méprise la plus fréquente lorsqu'on transporte les exemples de la documentation dans son propre composant.
Le reste du code ci-dessous t'est familier depuis les premiers modules : le décorateur @Component avec son champ selector, c'est-à-dire le nom de la balise, son champ standalone qui marque un composant autonome, et son champ template dans lequel les valeurs s'insèrent entre doubles accolades. Seuls les trois champs de la classe sont nouveaux.
1import { Component, signal, computed } from '@angular/core';
2
3@Component({
4 selector: 'app-tower-ledger',
5 standalone: true,
6 template: `
7 <p>Articles : {{ quantity() }}</p>
8 <p>Prix : {{ price() }}</p>
9 <p>Sous-total : {{ subtotal() }}</p>
10 `
11})
12export class TowerLedgerComponent {
13 price = signal(100);
14 quantity = signal(2);
15
16 subtotal = computed(() => this.price() * this.quantity());
17}Le template lit les trois signaux de la même manière, avec des parenthèses, et ne fait aucune différence entre une valeur dérivée et une valeur ordinaire - pour lui, subtotal() n'est qu'un nombre. La classe n'a gagné aucune méthode nouvelle au passage : pas de ngOnInit, pas de rafraîchissement manuel, rien d'autre que trois champs. Le jour où tu appelleras this.price.set(120) dans une méthode quelconque, Angular recalculera subtotal et ne rafraîchira que le fragment de vue qui s'en sert. Quant au décorateur, il se présente exactement comme tu l'as découvert dans le premier module auprès des guerriers indépendants - un sélecteur, la marque du composant autonome et un template.
Retiens ceci : set() est l'ordre "allume exactement tant de fumée", update() est l'ordre "ajoute à ce que l'on voit déjà", asReadonly() est la fenêtre ouverte aux hérauts, et computed() est le scribe qui lit les tours de lui-même et corrige son ardoise de lui-même - et au prochain relais tu apprendras quoi faire lorsque c'est quelqu'un venu de l'extérieur des murs de l'Académie qui doit réagir au changement de fumée.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quelle est la différence entre set() et update() pour un signal ?
2. Quelle méthode renvoie une version du signal qui ne peut pas être modifiée via set() ou update() ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne, sur une seule ligne, l'appel qui incrémente le signal count de 1 à partir de sa valeur précédente.
- Éditeur de code
Complète le composant panier : le total doit être un signal en lecture seule qui se recalcule tout seul à partir de la quantité et du prix.
- Tri vertical
Ordonne les étapes pour ajouter une nouvelle compétence à un signal contenant un objet samouraï (de façon immuable).
- Tri vertical
Ordonne les définitions de signals computed de façon à ce que chacune utilise la précédente (subtotal → tax → total).
- Clique dans l'ordre
Ordonne le début d'une définition de signal computed pour le prix total : full = ...