Angular Best Practices : Signals et RxJS sans confusion

🏷️ Front-end 📅 13/04/2026 16:00:00 👤 Mezgani said
Angular Best Practices Signals Rxjs State Management
Angular Best Practices : Signals et RxJS sans confusion

Quand utiliser Signals, quand garder RxJS, et comment faire coexister les deux proprement dans une application Angular moderne.

Signals vs RxJS

Le mauvais débat en Angular est de vouloir choisir entre Signals et RxJS comme si l'un devait remplacer totalement l'autre. En réalité, ils répondent à deux problèmes différents.

  • Signals excellent pour l'état local et la dérivation synchrone dans les composants.
  • RxJS reste très pertinent pour les flux asynchrones, le multicasting, les retries et les compositions complexes.
Règle simple: utilisez Signals pour représenter l'état visible de l'UI, RxJS pour orchestrer les flux externes et les opérations asynchrones complexes.

Etat local de composant

Pour l'état local, un signal est souvent plus lisible qu'un BehaviorSubject. Il élimine le bruit de next(), les subscriptions et une partie de la tuyauterie RxJS.

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

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    <p>Articles: {{ items().length }}</p>
    <p>Total: {{ total() }} €</p>
  `
})
export class CartComponent {
  readonly items = signal([{ price: 20 }, { price: 35 }]);
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );
}

Le code reste déclaratif, le template lit simplement items() et total(), et Angular suit les dépendances automatiquement.

Flux asynchrones et HTTP

Pour un appel HTTP simple, il est sain de conserver une source RxJS puis de l'exposer à l'UI sous forme de Signal. Cela garde la partie transport et la partie rendu bien séparées.

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

@Component({ standalone: true, template: `<pre>{{ users() | json }}</pre>` })
export class UsersComponent {
  private http = inject(HttpClient);

  readonly users = toSignal(
    this.http.get<unknown[]>('/api/users'),
    { initialValue: [] }
  );
}

Ici, l'Observable gère l'asynchronisme. Le Signal simplifie l'accès côté template.

Interop via toSignal et toObservable

Angular fournit une interop officielle. Elle permet d'éviter les migrations brutales et de moderniser progressivement l'application.

import { effect, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';

const search = signal('');
const search$ = toObservable(search);

// search$ peut ensuite etre pipe avec debounceTime, switchMap, etc.

Ce pattern est particulièrement utile pour relier une UI pilotée par Signals avec un pipeline RxJS de recherche, filtres ou autosave.

Erreurs frequentes

  • Remplacer tout RxJS sans stratégie, y compris les flux backend complexes.
  • Mettre des appels HTTP dans effect() sans contrôle de cycle de vie.
  • Stocker un état dérivé dans un signal au lieu d'utiliser computed().
  • Multiplier les conversions Signal/Observable sans vraie raison.

Le bon usage consiste à convertir seulement aux frontières utiles, pas à tout réécrire pour suivre une tendance.

Strategie recommandee

  • Etat de vue local: Signals.
  • Données réseau et orchestration async: RxJS.
  • Exposition au template: préférer une API simple, souvent sous forme de Signals.
  • Migrations: introduire toSignal() sur les features récentes d'abord.

Signals et RxJS ne s'opposent pas. Dans une application Angular mature, ils se complètent très bien.

effect() : réagir aux changements d'un signal

effect() est une fonction Angular qui s'exécute automatiquement chaque fois qu'un signal source change. Contrairement à computed() qui dérive une valeur, effect() déclenche des effets de bord (side effects).

Cas d'usage légitimes

  • Synchronisation avec localStorage : persister une préférence utilisateur à chaque changement de signal.
  • Intégration de bibliothèques tierces : D3.js, Chart.js ou tout outil non Angular nécessitant une notification explicite de mise à jour.
  • Logging et analytics : tracer les changements d'état pour le débogage ou les métriques produit.
import { Component, signal, effect } from '@angular/core';

@Component({ standalone: true, template: '...' })
export class ThemeComponent {
  readonly theme = signal<'light' | 'dark'>('light');

  constructor() {
    // Synchronisation avec localStorage — cas d'usage idéal pour effect()
    effect(() => {
      localStorage.setItem('theme', this.theme());
      document.documentElement.setAttribute('data-theme', this.theme());
    });
  }

  toggleTheme() {
    this.theme.update(t => t === 'light' ? 'dark' : 'light');
  }
}

Anti-pattern à éviter

Modifier un signal depuis un effect() peut provoquer des boucles de réaction infinies :

// ❌ Mauvais : modifier un signal dans un effect peut créer des boucles
effect(() => {
  if (this.count() > 10) {
    this.count.set(0); // danger : provoque un nouveau cycle effect
  }
});

// ✅ Mieux : utiliser computed() ou une logique dans le setter
readonly displayCount = computed(() => Math.min(this.count(), 10));

Si vous avez absolument besoin d'écrire dans un signal depuis un effect(), Angular propose l'option { allowSignalWrites: true } en second argument. Utilisez-la en dernier recours, uniquement lorsqu'aucune alternative avec computed() n'est possible.

Règle à retenir : effect() est un outil de synchronisation avec des effets de bord, pas un outil de dérivation d'état. Pour dériver de l'état, utilisez toujours computed().

linkedSignal() et resource() (Angular 19)

Angular 19 introduit deux nouvelles primitives qui complètent les Signals pour des cas d'usage avancés.

linkedSignal() : signal dérivé et modifiable

linkedSignal() combine signal() et computed() : il se calcule automatiquement depuis un autre signal, mais reste modifiable manuellement par l'utilisateur. Cas d'usage typique : la sélection par défaut dépend d'une liste parent, mais l'utilisateur peut la changer.

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

const products = signal([
  { id: 1, name: 'Angular' },
  { id: 2, name: 'React' }
]);

// Initialisé depuis products(), mais modifiable manuellement
const selectedId = linkedSignal(() => products()[0].id);

// L'utilisateur sélectionne manuellement
selectedId.set(2);
console.log(selectedId()); // 2

// Si products() change, selectedId se réinitialise
products.set([{ id: 10, name: 'Vue' }]);
console.log(selectedId()); // 10 (réinitialisé)

resource() : données asynchrones pilotées par un signal

resource() est une abstraction pour les données asynchrones liées à un signal. Chaque fois que le signal source change, la requête est relancée automatiquement.

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

const userId = signal(1);

const userResource = resource({
  request: () => ({ id: userId() }),
  loader: async ({ request }) => {
    const res = await fetch(`/api/users/${request.id}`);
    return res.json();
  }
});

// Dans le template :
// userResource.isLoading() → boolean
// userResource.value()     → données ou undefined
// userResource.error()     → erreur ou undefined
Compatibilité : linkedSignal() et resource() sont disponibles à partir d'Angular 19. Pour les versions antérieures, toSignal(httpClient.get(...)) reste la solution recommandée pour les données HTTP.

État global avec @ngrx/signals (Signal Store)

Quand l'état local d'un composant ne suffit plus — partage entre composants non liés, persistance entre routes — il faut une solution d'état global. @ngrx/signals (Signal Store) est l'alternative légère à NgRx Redux, construite entièrement sur les Signals Angular.

Signal Store s'articule autour de quatre fonctions composables :

  • withState() : définit la forme initiale de l'état.
  • withComputed() : déclare des signals dérivés (équivalents aux sélecteurs NgRx).
  • withMethods() : expose les mutations et actions (équivalents aux reducers).
  • patchState() : met à jour un sous-ensemble de l'état de façon immuable.
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';

type CartState = {
  items: { id: number; name: string; price: number }[];
  loading: boolean;
};

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState<CartState>({ items: [], loading: false }),

  withComputed(({ items }) => ({
    // Signal calculé : total du panier
    total: computed(() => items().reduce((sum, item) => sum + item.price, 0)),
    count: computed(() => items().length)
  })),

  withMethods((store) => ({
    addItem(item: { id: number; name: string; price: number }) {
      // patchState : met à jour un sous-ensemble de l'état
      patchState(store, { items: [...store.items(), item] });
    },
    removeItem(id: number) {
      patchState(store, { items: store.items().filter(i => i.id !== id) });
    }
  }))
);

L'injection du store dans un composant est directe via inject() :

import { Component, inject } from '@angular/core';
import { CartStore } from './cart.store';

@Component({ standalone: true, template: `
  <p>Panier : {{ cart.count() }} articles — {{ cart.total() }} €</p>
` })
export class CartComponent {
  readonly cart = inject(CartStore);
}
Signal Store vs NgRx : Signal Store est significativement plus léger que NgRx traditionnel et supprime le boilerplate Actions/Effects/Reducers pour les cas simples. Idéal pour les features isolées ou les applications sans besoin de time-travel debugging.