Les Angular Signals apportent une nouvelle façon de gérer l’état dans les applications Angular. Ils offrent un modèle plus simple et plus explicite pour l’état synchrone, en particulier au niveau des composants et des services. Après avoir migré les inputs, les outputs et l’état local, puis remplacé les BehaviorSubjects par des Signals dans les services, une question revient souvent :

Que faire de RxJS ?

Les Signals sont puissants, mais ils ne remplacent pas RxJS. La plupart des applications Angular existantes s’appuient fortement sur les Observables pour la logique asynchrone, les interactions utilisateur et les événements externes. Tenter de remplacer entièrement RxJS par les Signals conduit souvent à des solutions trop complexes ou à des bugs subtils.

Cet article propose une stratégie de migration pragmatique. Plutôt que de choisir entre les Signals et RxJS, nous allons voir comment répartir leurs responsabilités et les combiner efficacement au cours d’une migration progressive. À travers quelques exemples concrets, nous verrons quand les Signals simplifient le code, quand RxJS reste le bon outil et comment faire le lien entre les deux sans réécrire de grandes parties d’une base de code existante.

1. Signals ou RxJS : arrêtez de choisir, commencez à séparer

À l’introduction des Angular Signals, de nombreux développeurs se sont demandé si RxJS était encore nécessaire. En pratique, c’est souvent la mauvaise question. Les Signals et RxJS répondent à des besoins différents, et tenter de remplacer l’un par l’autre rend généralement le code plus difficile à comprendre.

Les Signals sont conçus pour gérer l’état synchrone. Ils conviennent bien à l’état de l’interface, aux valeurs dérivées et à toute donnée qui peut être lue immédiatement par le template ou par d’autres parties de l’application. Leur principal atout est leur simplicité : l’état est explicite, les dépendances sont claires et les mises à jour se font automatiquement via computed.

RxJS, en revanche, est conçu pour les flux asynchrones et les événements. Les appels HTTP, les interactions utilisateur, les timers, les WebSockets et les flux complexes qui évoluent dans le temps s’expriment toujours bien plus facilement avec les Observables et des opérateurs comme switchMap, debounceTime ou retry.

Un bon modèle mental consiste à cesser de choisir entre les Signals et RxJS et à commencer à séparer les responsabilités. Utilisez les Signals pour représenter l’état, et RxJS pour gérer les événements et les flux de travail asynchrones. Dans la plupart des applications réelles, une approche hybride donne un code plus simple et plus facile à maintenir que d’essayer de tout faire entrer dans un seul modèle.

État synchrone

readonly isOpen = signal(false);
readonly canSubmit = computed(() => this.isOpen() && this.isValid());

État asynchrone

search$ = this.searchInput$.pipe(
  debounceTime(300),
  switchMap(term => this.api.search(term))
);

2. Relier les signals et les observables

Migrer vers les Signals signifie rarement supprimer RxJS. La plupart du temps, l’objectif est de relier la logique existante basée sur les Observables à un modèle d’état basé sur les Signals. Angular fournit deux API importantes pour cela : toSignal() et toObservable().

Le cas le plus courant consiste à consommer un Observable et à exposer sa dernière valeur sous forme de signal. C’est utile lorsque les données proviennent d’un appel HTTP, d’un flux d’événements ou d’un service existant qui renvoie déjà des Observables.

readonly users = toSignal(
  this.userService.users$,
  { initialValue: [] }
);

Le template et la logique du composant peuvent ainsi travailler avec une valeur synchrone, tandis que RxJS continue de gérer le flux asynchrone.

Le cas inverse existe également. Une API ou une bibliothèque peut encore attendre un Observable alors que votre état interne est désormais géré avec des Signals. Dans ce cas, toObservable() simplifie l’intégration.

readonly filters$ = toObservable(this.filters);

Ces deux fonctions rendent la migration progressive. Elles permettent aux Signals et à RxJS de coexister dans la même base de code, sans imposer une réécriture complète ni introduire des abstractions complexes. En pratique, ce sont souvent les seuls outils nécessaires pour relier proprement les deux univers.

3. Transition courante d’un composant basé sur les observables vers les signals

Une étape de migration courante consiste à convertir un Observable utilisé directement dans un composant en signal. Cela permet de simplifier les templates et de moins dépendre de l’async pipe.

Avant : Observable + async pipe

@Component({
  selector: 'app-users',
  imports: [AsyncPipe],
  template: `
    <ul>
      <li *ngFor="let user of users$ | async">
        {{ user.name }}
      </li>
    </ul>
  `,
})
export class UsersComponent {
  private readonly usersService = inject(UsersService);

  public readonly users$ = this.usersService.users$;
}

Ce schéma est très courant et fonctionne bien. Cependant, il oblige à importer AsyncPipe et rend plus difficile la réutilisation des données dans la classe du composant.

Après : Observable relié à un signal

@Component({
  selector: 'app-users',
  template: `
    <ul>
      <li *ngFor="let user of users()">
        {{ user.name }}
      </li>
    </ul>
  `,
})
export class UsersComponent {
  private readonly usersService = inject(UsersService);

  public readonly users = toSignal(this.usersService.users$, {
    initialValue: [],
  });
}

Dans cette version, RxJS sert toujours à récupérer et à émettre les données, mais le composant travaille avec un signal synchrone. Le template est plus simple, et les données peuvent désormais être consultées directement dans la logique du composant, sans abonnement.

Cette approche est particulièrement utile pour une migration progressive : elle évite de modifier les services existants tout en permettant de profiter des Signals au niveau du composant.

4. Exemple hybride : champ de recherche avec filtrage

Imaginons un scénario très courant : une liste d’utilisateurs filtrée à partir d’un champ de recherche. Les événements du champ sont asynchrones, donc RxJS se charge de l’attente anti-rebond et des requêtes HTTP. L’état filtré est synchrone, nous pouvons donc utiliser un signal pour les utilisateurs filtrés.

@Component({
  selector: 'app-users-search',
  template: `
    <input type="text" [ngModel]="searchTerm()" (ngModelChange)="searchTerm.set($event)" />
    <ul>
      <li *ngFor="let user of filteredUsers()">
        {{ user.name }}
      </li>
    </ul>
  `,
})
export class UsersSearchComponent {
  // terme de recherche stocké dans un signal
  readonly searchTerm = signal('');

  // convertir le signal en Observable pour le flux asynchrone
  private search$ = toObservable(this.searchTerm).pipe(
    debounceTime(300),
    switchMap(term => this.api.searchUsers(term))
  );

  // utilisateurs récupérés de façon asynchrone
  readonly users = toSignal(this.search$, { initialValue: [] });

  // utilisateurs filtrés de façon synchrone
  readonly filteredUsers = computed(() =>
    this.users().filter(user =>
      user.name.toLowerCase().includes(this.searchTerm().toLowerCase())
    )
  );
}

Conclusion

Migrer vers les Signals ne signifie pas abandonner RxJS. Dans la plupart des applications réelles, les meilleurs résultats viennent d’une approche hybride :

  • Les Signals gèrent l’état synchrone et les valeurs calculées, ce qui simplifie les templates et rend la logique des composants plus lisible.
  • RxJS continue de gérer les flux asynchrones, les événements et les processus complexes au fil du temps.
  • toSignal() et toObservable() permettent une migration progressive, sans réécriture complète et avec un minimum d’effets secondaires.

Les fonctionnalités récentes comme resource et rxResource sont très prometteuses pour poursuivre cette migration (documentation : https://angular.dev/guide/signals/resource)

Elles offrent de nouveaux outils pour gérer les données asynchrones et les requêtes HTTP de manière réactive, et faciliteront encore l’intégration des Signals et de RxJS dans une même base de code. Un article dédié présentera ces API en détail.