Skip to main content

Command Palette

Search for a command to run...

Signals, La Réactivité d’Angular Simplifiée

Updated
9 min read
Signals, La Réactivité d’Angular Simplifiée

Dans cet article, vous trouverez :

  • Une brève histoire de la gestion de state dans Angular

  • Pourquoi Angular adopte les signals

  • Des exemples de gestion de state entre trois composants utilisant l'API @Input et @Output

  • Un exemple montrant comment les signals simplifient la gestion de state

  • Une explication des signals

  • Vue d'ensemble de l'API des signals

  • L'avenir d'Angular sans zone et basé sur les signals

Brève histoire de la gestion de state dans Angular

L'histoire de la gestion de state dans Angular a connu de grands changements et plusieurs évolutions. À ses débuts, avec AngularJS, il n'existait aucun modèle formel de gestion de state. C'est l'une des raisons qui ont poussé l'équipe Angular à repenser entièrement le framework et, en 2016, à introduire Angular 2. Angular 2 a marqué un tournant avec un flux de données unidirectionnel, contrairement à AngularJS où la liaison bidirectionnelle était la norme.

Dans AngularJS, les références aux objets enfants étaient maintenues dans le composant parent, ce qui signifiait qu'une mise à jour dans l'enfant affectait automatiquement le parent. Bien que, dans Angular 2, il ait été encore possible d'utiliser la liaison bidirectionnelle grâce à NgModel(), cette approche a progressivement été remplacée par l'API @Input() et @Output(), favorisant une liaison unidirectionnelle. Dans ce modèle, le parent reste informé des changements dans l'enfant, mais si l'enfant doit modifier une valeur stockée dans le parent, il doit envoyer un événement via @Output(). Cela impliquait que la gestion explicite des événements était toujours nécessaire.

Le flux unidirectionnel poussait clairement vers une structure hiérarchique, où les composants étaient organisés sous forme d'un arbre. Il encourageait également fortement le schéma consistant à diviser les composants entre "intelligents" et "représentationnels". Cependant, les règles n'étaient pas strictement définies, car Angular permettait encore l'utilisation de services injectés partout dans l'application, souvent utilisés pour partager des données entre plusieurs composants.

Complexité des applications lourdes

Dans les applications lourdes et complexes axées sur les données, les développeurs utilisaient souvent des bibliothèques de gestion de state globales comme NgRx ou NgXS. Bien que cela rendait possible la gestion des states complexes, cela ajoutait également de la complexité et beaucoup de code répétitif (boilerplate). En conséquence, le débogage des applications devenait parfois difficile, notamment pour retracer l'origine des modifications d'un state.

Transition vers les "Signals"

Afin de résoudre ces problèmes de longue date liés au modèle original de détection des changements et à la gestion de state des applications, l'équipe Angular se dirige de plus en plus vers les signals. Les signals visent à réduire le besoin d'utiliser des bibliothèques de gestion de states lourdes et complexes.

Exemple pratique

Prenons l'exemple de deux composants différents partageant un state commun. Auparavant, il était très courant d'utiliser des services combinés avec RxJS pour gérer cet state. Dans une application Angular typique, il était nécessaire de s'abonner explicitement aux modifications des valeurs. Voici un aperçu de ce fonctionnement traditionnel avant l'arrivée des "signals".

// service

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class RacletteService {
  private slicesSubject = new BehaviorSubject<number>(0);
  slices$ = this.slicesSubject.asObservable();

  addSlice() {
    this.slicesSubject.next(this.slicesSubject.value + 1);
  }

  removeSlice() {
    this.slicesSubject.next(0, this.slicesSubject.value - 1);
  }

  resetSlices() {
    this.slicesSubject.next(0);
  }
}
  • slicesSubject conserve la valeur actuelle et émet cette valeur à tous les abonnés.

  • slices$ est un observable auquel les composants peuvent s'abonner.

  • Le service, utilisé comme un "mini-store," contient des méthodes pour mettre à jour le state.

Le parent doit écouter les événements et mettre à jour le state. Le service doit mettre à jour la valeur, et tous les composants utilisant cette valeur doivent s'abonner aux changements.

Ainsi, nous aurions typiquement un composant qui met à jour le state :

import { Component } from '@angular/core';
import { RacletteService } from './raclette.service';

@Component({
  selector: 'raclette-controls',
  template: `
    <button (click)="raclette.addSlice()">Add Slice 🧀</button>
    <button (click)="raclette.removeSlice()">Remove Slice 🧀</button>
    <button (click)="raclette.resetSlices()">Reset 🧀</button>
  `
})
export class RacletteControlsComponent {
  constructor(public raclette: RacletteService) {}
}

et d'autres composants qui liraient cette state :

import { Component, OnInit } from '@angular/core';
import { RacletteService } from './raclette.service';

@Component({
  selector: 'raclette-display',
  template: `<p>Slices on the plate: {{ slices }}</p>`
})
export class RacletteDisplayComponent implements OnInit {
  slices = 0;

  constructor(private raclette: RacletteService) {}

  ngOnInit() {
    this.raclette.slices$.subscribe(value => {
      this.slices = value;
    });
  }
}

Tous les composants utilisant ce state devraient être abonnés à ses changements et les écouter afin de mettre à jour la vue en conséquence.
On peut constater que, si notre composant lit de nombreux states différents, il pourrait rapidement être surchargé par un grand nombre d'abonnements lors de son initialisation. Si les manipulations des states ne sont pas simples, nous nous retrouverions souvent à devoir imbriquer nos observables ou les consommer simultanément en utilisant .switchMap, .mergeMap, combineLatest, etc.

Imaginez que nous souhaitons ajouter des pommes de terre dans notre RacletteService et lire leur state en parallèle avec les states des tranches de fromage pour calculer un state dérivé :

// service

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class RacletteService {
  private potatoesSubject = new BehaviorSubject<number>(0);
  potatoes$ = this.potatoesSubject.asObservable();

  addPotato() {
    this.potatoesSubject.next(this.potatoesSubject.value + 1);
  }

  removePotato() {
    this.potatoesSubject.next(this.potatoesSubject.value - 1);
  }
}

Ensuite, dans notre composant d'affichage, nous ferions très souvent quelque chose comme ceci :

import { Component, OnInit } from '@angular/core';
import { RacletteService } from './raclette.service';
import { combineLatest } from 'rxjs';

@Component({
  selector: 'raclette-display',
  template: `
    <p>Raclette slices: {{ slices }}</p>
    <p>Potatoes: {{ potatoes }}</p>
    <p>Total items: {{ totalItems }}</p>
  `
})
export class RacletteDisplayComponent implements OnInit {
  slices = 0;
  potatoes = 0;
  totalItems = 0;

  constructor(private raclette: RacletteService) {}

  ngOnInit() {
    combineLatest([this.raclette.slices$, this.raclette.potatoes$])
      .subscribe(([slicesValue, potatoesValue]) => {
        this.slices = slicesValue;
        this.potatoes = potatoesValue;
        this.totalItems = slicesValue + potatoesValue;
      });
  }  
}

Ou une version moins propre que l'on voit souvent dans les applications Angular :

ngOnInit() {
  this.raclette.slices$.subscribe(slicesValue => {
    this.slices = slicesValue;
    this.raclette.potatoes$.subscribe(potatoesValue => {
      this.potatoes = potatoesValue;
      this.totalItems = slicesValue + potatoesValue;
    });
  });
}

Examinons maintenant comment les signals changent le gestion de state d'Angular :

Avant les signals, notre state était partagé. Avec les signals, tout state peut être rendu réactif par défaut, et le signal dans le template réagit automatiquement à ses changements. Plus besoin d'abonnements, ni d'émettre des événements pour indiquer à Angular qu'une valeur est susceptible de changer.

Nous ne dépendons plus de RxJS et Zone.js pour suivre le state de notre application. Les signals nous offrent une réactivité fine. Zone.js effectuait un monkey-patching sur toutes les tâches asynchrones, ce qui entraînait le déclenchement d'une détection de changement complète à travers tout l'arbre des composants. En revanche, les signals sont des primitives réactives (reactive primitives) qui suivent explicitement les dépendances. Lorsqu'un signal est mis à jour, Angular sait quels composants consomment ce signal et ne met à jour que ceux-ci, pas tout l'arbre.

Avec une réactivité fine, les signals apportent également des moyens plus simples de gérer le state et moins de code. Prenons le même code écrit avec les signals :

// raclette.service.ts
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class RacletteService {
  slices = signal(0);
  potatoes = signal(0);
  totalItems = computed(() => this.slices() + this.potatoes());

  addSlice() {
    this.slices.update(n => n + 1);
  }

  removeSlice() {
    this.slices.update(n => 0, n - 1);
  }
  addPotato() {
    this.potatoes.update(n => n + 1);
  }

  removePotato() {
    this.potatoes.update(n => 0, n - 1);
  }
}

Composant 1 :

import { Component } from '@angular/core';
import { RacletteService } from './raclette.service';

@Component({
  selector: 'raclette-controls',
  template: `
    <button (click)="raclette.addSlice()">Add Slice 🧀</button>
    <button (click)="raclette.removeSlice()">Remove Slice 🧀</button>
    <button (click)="raclette.resetSlices()">Reset 🧀</button>
    <button (click)="raclette.addPotato()">Add Potato 🥔</button>
    <button (click)="raclette.removePotato()">Remove Potato 🥔</button>
  `
})
export class RacletteControlsComponent {
  constructor(public raclette: RacletteService) {}
}

Composant 2 :

import { Component } from '@angular/core';
import { RacletteService } from './raclette.service';

@Component({
  selector: 'raclette-display',
  template: `
    <p>Raclette slices: {{ raclette.slices() }}</p>
    <p>Potatoes: {{ raclette.potatoes() }}</p>
    <p>Total items: {{ raclette.totalItems() }}</p>
  `
})
export class RacletteDisplayComponent {
  constructor(public raclette: RacletteService) {}
}

Nous pouvons immédiatement comprendre pourquoi, avec les signals, les abonnements deviennent souvent inutiles. Les signals d'Angular "enveloppent" une valeur et notifient tous les consommateurs lorsque cette valeur est modifiée. De plus, nous bénéficions d'une gestion et d'un accès plus simples aux valeurs calculées, sans besoin d'utiliser combineLatest(). Le code devient ainsi plus clair et plus lisible.

Pour parvenir à cela, l'équipe Angular s'est tournée vers certaines fonctionnalités avancées de TypeScript.
Examinons ce qui se cache vraiment derrière la "magie" des signals d'Angular.

L'API ressemble à ceci :

type Signal<T> = (() => T) & {
  [SIGNAL]: unknown;
}

Expliquons cela :

(() => T) — est une fonction type, ce qui signifie que notre signal est appelable comme n'importe quelle autre fonction. Lorsqu'on l'appelle, il retourne une valeur de type T. Cela nous permet d'écrire du code comme ceci :

const slices: Signal<number> = () => 3;

slices(); // 3

Mais en même temps, notre signal est une propriété marquée (branded property) :

{
  [SIGNAL]: unknown;
}

Cette propriété SIGNAL n'est pas destinée à être utilisée et existe uniquement pour marquer (ou plus précisément pour identifier) une valeur comme une valeur signal. Elle retourne unknown car nous ne nous soucions pas de la valeur qu'elle renvoie.

Le typage du signal comme valeur d'intersection (&) indique une valeur qui est à la fois une fonction ((() => T)) et possède une propriété SIGNAL spéciale ({ SIGNAL: unknown; }). Cette propriété SIGNAL ajoutée empêche notre compilateur de traiter toutes les fonctions comme des signals, car seules les valeurs créées en tant que signals peuvent correspondre à ce type :

type Signal<T> = () => T;
const fn = () => 123;
const s: Signal<number> = fn; // notre signal peut etre une fonction

Mais le code suivant générera une erreur :

const fn = () => 123; 
const s: Signal<number> = fn; // notre fonction ne peut pas être un signal car    //manque la propriété [SIGNAL].

Ainsi, un signal Angular est une valeur qui :

  1. peut être appelée comme une fonction retournant T, et

  2. possède une propriété dont la clé est exactement SIGNAL.

De plus, l'équipe Angular nous offre une option qui, si nous marquons notre signal comme modifiable (writable), nous donne accès à une API permettant de gérer son state, comme set() pour définir le state du signal ou update() pour calculer une nouvelle valeur à partir de l'ancienne. Nous avons également la possibilité de retourner la valeur du signal comme étant en read-only.

interface WritableSignal<T> extends Signal<T> {
  set(value: T): void;
  update(updateFn: (value: T) => T): void;
  asReadonly(): Signal<T>;
  override [SIGNAL]: unknown;
}

Signals : Vers un Angular plus Moderne

Un autre grand avantage des signals est la prévention des fuites mémoire. Il n'est plus nécessaire de se désabonner comme c'était le cas avec les Observables.
Avec les signals, la "subscription" est automatique dans le template. Les fuites mémoire sont plus difficiles à produire, car Angular détruira les références des signals lorsque l'utilisateur quittera la page, c'est-à-dire lorsque le composant sera détruit.

En introduisant les signals, Angular évolue vers des frameworks modernes, plus réactifs et flexibles. Nous pouvons observer cette idée se développer avec Angular 21 (en développement, prévu en novembre 2025), qui sera désormais zoneless par défaut.
ZoneJS utilisait les événements DOM et les tâches asynchrones pour indiquer quand le state "pourrait" changer. Cela provoquait de nombreux problèmes de performance. En le supprimant, on garantit que la détection des changements ne se déclenche plus sur toutes les opérations asynchrones.

Angular 21 exploite également les signals pour gérer le state des formulaires en introduisant (encore en version préliminaire) des formulaires alimentés par les signals. Plus d'informations à ce sujet dans le prochain article.

Ressources supplémentaires :

Documentation officielle Angular :

L’IMAGE: https://unsplash.com/fr/photos/homme-tenant-de-la-poudre-rose-xW4bS_Rj5Po?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink

More from this blog

N

Niji.tech, c’est le meilleur de la tech by Niji

54 posts

Niji.tech, c’est le meilleur de la tech by Niji :
du web, du mobile, de l'agile, du design, de l'IA, des bonnes pratiques, et plus encore !