<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Signaux angulaires vs RXJS : Quand utiliser l'un et quand l'autre

Cet article explique la différence entre Angular Signals et RxJS, quand utiliser l'un, quand utiliser l'autre et comment les combiner dans de vraies applications Angular.


Angular Signals et RxJS ne sont pas deux outils en guerre l'un contre l'autre. Ce sont deux outils différents, créés pour résoudre des problèmes différents, et dans les applications angulaires modernes, ils fonctionnent souvent très bien ensemble.

Le problème se pose lorsqu’on les compare comme s’ils étaient interchangeables à cent pour cent. En réalité, les signaux sont parfaits pour représenter un état actuel, lisible et dérivable de manière synchrone. RxJS, en revanche, est parfait pour modéliser des flux, des événements, des suppressions, des anti-rebonds, des tentatives, des WebSockets et des combinaisons complexes de flux asynchrones dans le temps.

La bonne question n'est donc pas : « Signaux ou RxJS ? ». La bonne question est : « Est-ce que je gère un état actuel ou est-ce que je gère un flux d'événements au fil du temps ? ».


Le modèle mental : valeur actuelle vs flux dans le temps

A Signal est comme une variable réactive. Contient une valeur actuelle. Lorsque cette valeur change, Angular sait quelles parties de l'interface doivent être mises à jour.

An Observable RxJS est une séquence de valeurs au fil du temps. Il peut générer zéro, une ou plusieurs valeurs. Il peut se terminer, il peut générer une erreur, il peut être désabonné et il peut être transformé avec des opérateurs comme map, filter, switchMap, debounceTime, réessayer et bien d'autres.

Scénario Signaux RxJS
État local de l'interface utilisateur Excellent Possible, mais souvent excessif
Valeurs dérivées Excellent avec calculé Possible avec opérateurs
Appel unique HTTP Utile pour sauvegarder le résultat Excellent car HttpClient renvoie Observable
Recherche anti-rebond À combiner avec RxJS Excellent
WebSocket Utile pour afficher le dernier statut Idéal pour le streaming
Suppression des demandes Pas son meilleur cas Excellent avec switchMap
Modèle angulaire Très lisible Très bien avec async pipe

Que sont les signaux angulaires

Les signaux sont un système de réactivité intégré à Angular. Un signal contient une valeur et permet à Angular de suivre automatiquement qui lit cette valeur.

Lorsqu'un composant lit un signal dans le modèle, Angular peut mettre à jour cette partie de l'interface utilisateur lorsque le signal change. Cela rend l'état plus explicite et réduit le besoin de nombreux modèles basés sur le sujet ou le comportement pour les cas simples.

import { computed, signal } from '@angular/core';

const count = signal(0);

const double = computed(() => count() * 2);

function increment(): void {
  count.update((value) => value + 1);
}

Dans cet exemple, count est l'état principal, tandis que double est un état dérivé. Nous n'avons pas besoin de mettre à jour manuellement double : Angular le recalcule lorsque count.

change

Quand les signaux sont idéaux

  • Lorsque vous devez gérer l'état local d'un composant.
  • Lorsque vous devez afficher ou masquer des parties de l'interface utilisateur.
  • Lorsque vous devez gérer des filtres locaux.
  • Lorsque vous devez calculer des valeurs dérivées.
  • Lorsque vous souhaitez éviter les abonnements manuels inutiles.
  • Lorsque vous souhaitez créer un magasin simple pour une fonctionnalité.
  • Lorsque l'état est synchrone et représente toujours « la valeur actuelle ».

Qu'est-ce que RxJS dans Angular

RxJS est une bibliothèque de programmation réactive basée sur Observable. Dans Angular, il est utilisé depuis de nombreuses années et est toujours au cœur de nombreuses API du framework.

HttpClient renvoie Observable. Les formes réactives exposent valueChanges et statusChanges. Le routeur expose les paramètres, les paramètres de requête et les événements en tant qu'observables. Les WebSockets, les minuteries, les événements DOM et les flux complexes sont également très bien modélisés avec RxJS.

this.searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((query) => this.api.searchProducts(query))
);

Cet exemple est typique : l'utilisateur écrit dans un champ de recherche, on attend 300 millisecondes, on ignore les valeurs égales et on supprime automatiquement la requête précédente si une nouvelle recherche arrive.

Quand RxJS est idéal

  • Lorsque vous avez des événements asynchrones dans le temps.
  • Lorsque vous devez anti-rebondir ou accélérer.
  • Lorsque vous devez supprimer des demandes précédentes.
  • Lorsque vous devez combiner plusieurs sources asynchrones.
  • Lorsque vous travaillez avec des WebSockets ou des flux en direct.
  • Lorsque vous devez gérer les nouvelles tentatives, les délais d'attente ou les interrogations.
  • Lorsque vous devez orchestrer des flux complexes.

Règle générale simple

Utilisez Signaux pour représenter l'état actuel de l'interface utilisateur.

Utilisez calculé pour les valeurs dérivées de l'état.

Utilisez RxJS pour les événements asynchrones, les flux temporels et la logique d'effacement.

Utilisez toSignal lorsque vous souhaitez exposer un observable comme état lisible dans le modèle.

Utilisez toObservable lorsqu'un signal doit entrer dans un pipeline RxJS.


Exemple réel 1 : liste de produits avec filtres locaux

Imaginons une page e-commerce avec une liste de produits déjà chargée. L'utilisateur peut filtrer par texte, catégorie et disponibilité. Dans ce cas, les signaux sont un choix très naturel.

Le statut principal comprend les produits, la requête, la catégorie sélectionnée et l'indicateur « uniquement disponible ». Les produits filtrés sont un état dérivé.

import { computed, inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';

export interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
  stock: number;
}

@Injectable()
export class ProductsStore {
  private readonly api = inject(ProductsApiService);

  private readonly _products = signal<Product[]>([]);
  private readonly _loading = signal(false);
  private readonly _error = signal<string | null>(null);

  private readonly _query = signal('');
  private readonly _category = signal<string | null>(null);
  private readonly _onlyAvailable = signal(false);

  readonly products = this._products.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();

  readonly query = this._query.asReadonly();
  readonly category = this._category.asReadonly();
  readonly onlyAvailable = this._onlyAvailable.asReadonly();

  readonly filteredProducts = computed(() => {
    const query = this._query().toLowerCase().trim();
    const category = this._category();
    const onlyAvailable = this._onlyAvailable();

    return this._products().filter((product) => {
      const matchesQuery = product.name.toLowerCase().includes(query);
      const matchesCategory = !category || product.category === category;
      const matchesAvailability = !onlyAvailable || product.stock > 0;

      return matchesQuery && matchesCategory && matchesAvailability;
    });
  });

  readonly total = computed(() => this.filteredProducts().length);

  async loadProducts(): Promise<void> {
    this._loading.set(true);
    this._error.set(null);

    try {
      const products = await firstValueFrom(this.api.getProducts());
      this._products.set(products);
    } catch {
      this._error.set('Impossibile caricare i prodotti.');
    } finally {
      this._loading.set(false);
    }
  }

  setQuery(query: string): void {
    this._query.set(query);
  }

  setCategory(category: string | null): void {
    this._category.set(category);
  }

  setOnlyAvailable(value: boolean): void {
    this._onlyAvailable.set(value);
  }
}

Ce magasin est facile à lire. La donnée principale est le signal. Les données dérivées sont calculées. Le composant peut simplement afficher l'état.

import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';

@Component({
  selector: 'app-products-page',
  standalone: true,
  template: `
    <section class="products-page">
      <h2>Prodotti</h2>

      <input
        type="search"
        placeholder="Cerca prodotto..."
        [value]="store.query()"
        (input)="store.setQuery($any($event.target).value)"
      />

      <label>
        <input
          type="checkbox"
          [checked]="store.onlyAvailable()"
          (change)="store.setOnlyAvailable($any($event.target).checked)"
        />
        Solo disponibili
      </label>

      @if (store.loading()) {
        <p>Caricamento...</p>
      } @else if (store.error()) {
        <p class="error">{{ store.error() }}</p>
      } @else {
        <p>Risultati: {{ store.total() }}</p>

        @for (product of store.filteredProducts(); track product.id) {
          <article class="product-card">
            <h3>{{ product.name }}</h3>
            <p>Categoria: {{ product.category }}</p>
            <p>Prezzo: {{ product.price }} €</p>
          </article>
        }
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductsPageComponent implements OnInit {
  readonly store = inject(ProductsStore);

  ngOnInit(): void {
    void this.store.loadProducts();
  }
}

Ici, utiliser RxJS pour chaque petit filtre serait possible, mais probablement plus verbeux. Les signaux rendent le code plus direct.


Exemple réel 2 : recherche backend avec Debounce et Clear

Imaginons maintenant une recherche qui interroge le backend chaque fois que le texte change. Dans ce cas, RxJS devient beaucoup plus adapté.

La raison est simple : nous ne voulons pas envoyer de requête pour chaque personnage. Nous voulons attendre un petit intervalle, ignorer les valeurs égales, annuler la requête précédente et gérer les erreurs.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  shareReplay,
  startWith,
  switchMap
} from 'rxjs';

@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <section>
      <h2>Cerca prodotti</h2>

      <input
        type="search"
        [formControl]="searchControl"
        placeholder="Scrivi almeno 2 caratteri..."
      />

      @if (results$ | async; as results) {
        @for (product of results; track product.id) {
          <p>{{ product.name }}</p>
        }
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchComponent {
  private readonly api = inject(ProductsApiService);

  readonly searchControl = new FormControl('', { nonNullable: true });

  readonly results$ = this.searchControl.valueChanges.pipe(
    startWith(this.searchControl.value),
    map((value) => value.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((query) => {
      if (query.length < 2) {
        return of([]);
      }

      return this.api.searchProducts(query).pipe(
        catchError(() => of([]))
      );
    }),
    shareReplay({ bufferSize: 1, refCount: true })
  );
}

Ici switchMap est crucial. Si l'utilisateur tape « iph » puis immédiatement « iphone », la requête précédente peut être annulée et le composant n'utilisera que le résultat le plus récent.

Il s'agit d'un exemple classique où RxJS est plus naturel que Signals.


Exemple réel 3 : Utilisation conjointe de RxJS et de signaux

Dans de nombreux cas, la meilleure solution consiste à utiliser RxJS pour le flux asynchrone et les signaux pour exposer le résultat à l'interface utilisateur.

Nous pouvons partir d'un observable et le convertir en signal avec toSignal.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  startWith,
  switchMap
} from 'rxjs';

@Component({
  selector: 'app-product-search-signal',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <section>
      <input
        type="search"
        [formControl]="searchControl"
        placeholder="Cerca prodotto..."
      />

      @for (product of results(); track product.id) {
        <p>{{ product.name }}</p>
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchSignalComponent {
  private readonly api = inject(ProductsApiService);

  readonly searchControl = new FormControl('', { nonNullable: true });

  private readonly results$ = this.searchControl.valueChanges.pipe(
    startWith(this.searchControl.value),
    map((value) => value.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((query) => {
      if (query.length < 2) {
        return of([]);
      }

      return this.api.searchProducts(query).pipe(
        catchError(() => of([]))
      );
    })
  );

  readonly results = toSignal(this.results$, {
    initialValue: [] as Product[]
  });
}

Le pipeline reste RxJS, car il doit gérer le temps, l'anti-rebond et la suppression. Cependant, le modèle lit un signal, c'est donc plus simple.


Exemple réel 4 : détail du produit avec paramètre d'itinéraire

Un autre scénario très courant est celui d'une page de détail. Le produit à charger dépend de l'identifiant présent dans la route.

Le paramètre route est un flux. L'appel HTTP est un flux. L'interface utilisateur, en revanche, ne souhaite que l'état actuel du produit.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, distinctUntilChanged, map, of, switchMap } from 'rxjs';

@Component({
  selector: 'app-product-detail-page',
  standalone: true,
  template: `
    @if (product(); as product) {
      <article>
        <h2>{{ product.name }}</h2>
        <p>{{ product.description }}</p>
        <p>Prezzo: {{ product.price }} €</p>
      </article>
    } @else {
      <p>Prodotto non trovato.</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductDetailPageComponent {
  private readonly route = inject(ActivatedRoute);
  private readonly api = inject(ProductsApiService);

  private readonly product$ = this.route.paramMap.pipe(
    map((params) => params.get('id')),
    distinctUntilChanged(),
    switchMap((id) => {
      if (!id) {
        return of(null);
      }

      return this.api.getProductById(id).pipe(
        catchError(() => of(null))
      );
    })
  );

  readonly product = toSignal(this.product$, {
    initialValue: null as Product | null
  });
}

Ici, le meilleur choix n'est pas seulement Signals ou simplement RxJS. Le meilleur choix est d'utiliser les deux : RxJS pour réagir au changement de cap et Signals pour afficher la valeur actuelle dans le modèle.


Exemple réel 5 : total du panier et de la commande

Un chariot est un exemple parfait pour les signaux. Le panier contient une liste de lignes. À partir de ces lignes, nous pouvons déduire la quantité totale, le sous-total, les taxes et le total final.

import { computed, Injectable, signal } from '@angular/core';

export interface CartItem {
  productId: number;
  name: string;
  quantity: number;
  unitPrice: number;
}

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();

  readonly totalQuantity = computed(() =>
    this._items().reduce((total, item) => total + item.quantity, 0)
  );

  readonly subtotal = computed(() =>
    this._items().reduce(
      (total, item) => total + item.quantity * item.unitPrice,
      0
    )
  );

  readonly vat = computed(() => this.subtotal() * 0.22);

  readonly total = computed(() => this.subtotal() + this.vat());

  addItem(item: CartItem): void {
    this._items.update((items) => {
      const existing = items.find((current) => current.productId === item.productId);

      if (!existing) {
        return [...items, item];
      }

      return items.map((current) =>
        current.productId === item.productId
          ? { ...current, quantity: current.quantity + item.quantity }
          : current
      );
    });
  }

  removeItem(productId: number): void {
    this._items.update((items) =>
      items.filter((item) => item.productId !== productId)
    );
  }

  clear(): void {
    this._items.set([]);
  }
}

Dans ce cas, utiliser RxJS serait possible, mais les signaux sont plus simples. Le total est toujours dérivé de l'état actuel. Il n’est pas nécessaire de penser à une chronologie des événements.


Exemple réel 6 : notifications en direct avec WebSocket

Les notifications en direct, en revanche, constituent un cas beaucoup plus approprié pour RxJS. Un WebSocket émet des messages au fil du temps. Peut abandonner, se reconnecter, émettre des erreurs, produire des événements continus.

import { Injectable } from '@angular/core';
import { retry, share } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';

export interface NotificationMessage {
  id: string;
  title: string;
  body: string;
  createdAt: string;
}

@Injectable({ providedIn: 'root' })
export class NotificationsService {
  readonly messages$ = webSocket<NotificationMessage>(
    'wss://example.com/notifications'
  ).pipe(
    retry({ delay: 1000 }),
    share()
  );
}

Si nous voulons afficher la dernière notification dans le modèle, nous pouvons la convertir en signal.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-notification-bell',
  standalone: true,
  template: `
    @if (lastMessage(); as message) {
      <button type="button">
        🔔 {{ message.title }}
      </button>
    } @else {
      <button type="button">🔔 Nessuna notifica</button>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationBellComponent {
  private readonly notifications = inject(NotificationsService);

  readonly lastMessage = toSignal(this.notifications.messages$, {
    initialValue: null as NotificationMessage | null
  });
}

RxJS gère le flux. Signal expose la dernière valeur à l'interface utilisateur. C’est le type de combinaison le plus sain.


Interopérabilité : toSignal et toObservable

Angular fournit des outils permettant de faire communiquer Signals et RxJS. Les plus courants sont toSignal et toObservable.

toSignal convertit un observable en signal.

readonly user = toSignal(this.authService.user$, {
  initialValue: null
});

toObservable convertit un signal en Observable.

import { signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

readonly query = signal('');

private readonly query$ = toObservable(this.query);

readonly results = toSignal(
  this.query$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((query) => this.api.searchProducts(query))
  ),
  { initialValue: [] as Product[] }
);

Cette technique est très utile lorsque les entrées de l'utilisateur sont traitées comme des signaux, mais la recherche doit utiliser des opérateurs RxJS.


Erreurs courantes à éviter

1. Remplacez RxJS partout simplement parce que les signaux

existent

Les signaux ne suppriment pas RxJS. Angular continue d'utiliser Observable dans de nombreuses API. Si vous essayez de remplacer chaque flux par un signal, vous risquez de perdre les opérateurs les plus puissants de RxJS.

2. Utilisez RxJS pour chaque petit état local

Un booléen comme isMenuOpen, un onglet actif ou un filtre local n'a pas besoin d'un BehaviorSubject. Un signal est plus lisible.

3. Effectuez directement les appels HTTP sans vérifier

effect est utile pour les effets secondaires tels que la synchronisation de localStorage, du titre de la page ou du journal. Il ne devrait pas devenir le lieu où cacher une logique asynchrone complexe.

// Da evitare in scenari complessi:
effect(() => {
  const query = this.query();
  this.api.searchProducts(query).subscribe();
});

Pour l'anti-rebond, le nettoyage et la gestion des erreurs, un pipeline RxJS est plus propre.

4. Créer trop de calculs inutiles

calculed est puissant, mais il n'est pas nécessaire qu'il remplace chaque méthode ou chaque petite expression. À utiliser lorsque la valeur dérivée est importante, réutilisée ou coûteuse à calculer.

5. Convertissez d'avant en arrière sans motif

Si vous avez un observable utilisé uniquement dans le modèle, le canal async peut suffire. Utilisez toSignal lorsque vous souhaitez vraiment intégrer cette valeur dans le graphique des signaux.


Arbre de décision pratique

  • Est-ce un état local du composant ? Utilisez des signaux.
  • Est-ce une valeur dérivée d'autres États ? Utilisez calculé.
  • Est-ce un flux d'événements dans le temps ? Utilisez RxJS.
  • Besoin d'un anti-rebond ? Utilisez RxJS.
  • Devez-vous supprimer les demandes précédentes ? Utilisez RxJS avec switchMap.
  • Devez-vous afficher la dernière valeur du modèle ? Considérez toSignal.
  • Est-ce une liste déjà chargée pour être filtrée localement ? Signaux.
  • Est-ce une recherche à distance pendant que l'utilisateur écrit ? RxJS.
  • Est-ce un panier avec des totaux dérivés ? Signaux.
  • Est-ce un WebSocket ? RxJS.
  • Est-ce un formulaire avec validation asynchrone ? RxJS plus Signaux si vous avez besoin du statut UI.
  • Est-ce un simple magasin de fonctionnalités ? Signaux.
  • L'orchestration de plusieurs appels HTTP est-elle dépendante ? RxJS.

Architecture recommandée dans une application réelle

Dans un projet Angular professionnel, une bonne stratégie consiste à séparer la logique en une couche accès aux données. Les composants ne doivent pas en savoir trop sur HTTP, la mise en cache, les nouvelles tentatives ou le mappage de données.

src/app/features/products/
  products.routes.ts
  pages/
    products-page.component.ts
    product-detail-page.component.ts
  components/
    product-card.component.ts
    product-filters.component.ts
  data-access/
    products-api.service.ts
    products-store.service.ts
  models/
    product.model.ts

Dans le fichier de service API, vous pouvez utiliser RxJS car Angular HttpClient renvoie Observable.

Dans le magasin, vous pouvez enregistrer le résultat dans Signaux, créer des filtres et des totaux calculés et exposer des méthodes simples au composant.

Dans le composant, vous pouvez lire les signaux dans le modèle, utiliser le tube async lorsque vous travaillez directement avec Observable, ou convertir avec toSignal lorsque cela améliore la lisibilité.


Les signaux et RxJS ne s'excluent pas mutuellement. Les signaux facilitent Angular pour l'état local, l'état dérivé et l'interface utilisateur. RxJS reste essentiel pour les flux asynchrones, les flux, les suppressions, les nouvelles tentatives, l'anti-rebond et la composition avancée.

Le meilleur choix est d'utiliser le bon outil au bon endroit. Si vous représentez une valeur actuelle, choisissez Signaux. Si vous modélisez des événements dans le temps, choisissez RxJS. Si vous avez besoin des deux, combinez-les sans forcer.


💬 Notes des lecteurs

0 notes

Écrire une note

Partagez votre avis, une suggestion ou un compliment

Dernières notes

Aucune note pour le moment. Soyez le premier à commenter !