Lecture : 13 minutes · Niveau : intermédiaire-avancé · Mise à jour : avril 2026
Angular en 2026 propose deux modèles réactifs côte à côte : les signals (modernes, simples, synchrones) et RxJS (puissant, asynchrone, mature). Ne pas confondre les deux et savoir quand utiliser lequel est la clé pour écrire du code Angular moderne efficace. Ce guide décortique chaque modèle, leurs forces, leurs interactions et les patterns qui marchent.
L’arrivée des signals a profondément modifié la pratique Angular. Avant 2024, RxJS était omniprésent : BehaviorSubject pour partager de l’état, async pipe partout, opérateurs de composition pour les calculs dérivés. Cette approche était puissante mais exigeait une vraie maîtrise de RxJS, ce qui ralentissait l’onboarding des nouveaux développeurs et produisait du code parfois sur-réactif. Les signals simplifient drastiquement les cas synchrones tout en laissant RxJS régner sur l’asynchrone. Cette nouvelle division du travail rend Angular plus accessible sans sacrifier la puissance qui fait sa réputation.
Voir aussi → Angular pour entreprise : guide pratique.
Sommaire
- Signals vs RxJS : la division du travail
- Signals : les trois primitives
- Patterns courants avec signals
- RxJS : les opérateurs qui comptent vraiment
- HTTP avec RxJS
- Interop : toSignal et toObservable
- Pièges classiques
- Performance et change detection
- FAQ
1. Signals vs RxJS : la division du travail
Le bon réflexe en 2026 : commencer par les signals, descendre à RxJS quand vraiment nécessaire.
Signals pour :
– État UI synchrone (compteur, mode édition, valeurs de filtres)
– Données dérivées calculables synchrones
– État de composant
– Communication parent-enfant via inputs/outputs en signals
– État partagé entre composants via services exposant des signals
RxJS pour :
– Requêtes HTTP (HttpClient retourne des Observables)
– Événements DOM ou WebSocket
– Combinaisons asynchrones complexes (debounce, throttle, retry, switchMap)
– Streams temporels (intervals, timers)
– Backpressure et gestion de flux
Beaucoup de cas qu’on faisait avec RxJS avant 2024 (subjects pour partager de l’état) sont maintenant plus simples avec des signals. Les Observables restent pertinents pour ce qui est vraiment asynchrone et continu.
2. Signals : les trois primitives
signal()
Conteneur de valeur réactive.
import { signal } from "@angular/core";
const count = signal(0);
count(); // lecture : retourne 0
count.set(5); // écriture directe
count.update(c => c + 1); // écriture basée sur la valeur précédente
Dans un template :
<p>Compteur : {{ count() }}</p>
L’appel count() lit la valeur. Si elle change, Angular re-rend automatiquement le fragment du template qui en dépend.
computed()
Valeur dérivée. Recalculée automatiquement quand les dépendances changent. Mémorisée : si on la lit plusieurs fois sans que les deps changent, le calcul ne refait pas.
const items = signal<Item[]>([]);
const search = signal("");
const filtered = computed(() =>
items().filter(item => item.nom.includes(search()))
);
// Dans le template
@for (item of filtered(); track item.id) {
<li>{{ item.nom }}</li>
}
filtered se recalcule uniquement quand items ou search change.
effect()
Exécute du code à chaque changement des signals utilisés. Pour des effets de bord (logging, sync localStorage, appels imperatifs).
import { effect } from "@angular/core";
@Component(...)
export class MyComponent {
user = signal<User | null>(null);
constructor() {
effect(() => {
const u = this.user();
if (u) {
console.log(`Utilisateur : ${u.nom}`);
localStorage.setItem("lastUser", JSON.stringify(u));
}
});
}
}
Le effect est nettoyé automatiquement quand le composant est détruit (si appelé dans un contexte d’injection).
3. Patterns courants avec signals
State partagé via service
@Injectable({ providedIn: "root" })
export class CartService {
items = signal<CartItem[]>([]);
count = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, i) => sum + i.prix * i.quantite, 0)
);
add(item: CartItem) {
this.items.update(list => [...list, item]);
}
remove(id: string) {
this.items.update(list => list.filter(i => i.id !== id));
}
clear() {
this.items.set([]);
}
}
Plusieurs composants peuvent injecter CartService et lire ses signals. Toute modification est propagée automatiquement.
Inputs avec signals
import { input } from "@angular/core";
@Component({
selector: "app-card",
standalone: true,
template: `<div>{{ titre() }}</div>`,
})
export class CardComponent {
titre = input.required<string>();
variant = input<"default" | "primary">("default");
}
input() (Angular 17.1+) crée un signal en lecture seule. Plus moderne que @Input(). Permet d’utiliser computed() qui dépend des inputs sans recourir à ngOnChanges.
Outputs
import { output } from "@angular/core";
@Component(...)
export class CardComponent {
clicked = output<string>();
onClick() {
this.clicked.emit("card-1");
}
}
output() remplace @Output() new EventEmitter(). Plus simple, type-safe.
Two-way binding avec model
import { model } from "@angular/core";
@Component({
selector: "app-input",
template: `<input [value]="value()" (input)="value.set($event.target.value)" />`,
})
export class InputComponent {
value = model.required<string>();
}
Permet [(value)]="userInput" au parent. Synchronise dans les deux sens automatiquement, ce qui simplifie sensiblement les composants de formulaire personnalisés en évitant le boilerplate des EventEmitter manuels.
4. RxJS : les opérateurs qui comptent vraiment
Pas besoin de connaître les 200 opérateurs RxJS. Une vingtaine couvrent 95% des cas.
Transformation
map(fn): transforme chaque valeurfilter(predicate): filtre les valeurstap(fn): effet de bord sans modifier la valeur (pour debug, log)
Async control
switchMap(fn): remplace par un nouvel Observable, annule le précédent. Idéal pour HTTP qui annule la requête précédentemergeMap(fn): exécute en parallèle, ne préserve pas l’ordreconcatMap(fn): exécute séquentiellement, préserve l’ordreexhaustMap(fn): ignore les nouvelles émissions tant que la précédente n’est pas terminée
Temporel
debounceTime(ms): émet uniquement après N ms d’inactivitéthrottleTime(ms): émet maximum une fois par N msdelay(ms): retarde toutes les émissions
Combinaison
combineLatest([a$, b$]): émet quand au moins une source a émis et qu’elles ont toutes émis au moins une foisforkJoin([a$, b$]): équivalent Promise.all, attend toutes les sources, émet une seule foismerge(a$, b$): combine plusieurs sources
Filtre
distinctUntilChanged(): émet uniquement si la valeur changetake(n): prend les N premières émissions puis complètetakeUntil(notifier$): prend jusqu’à ce qu’une autre source émette
Erreurs
catchError(fn): remplace une erreur par une valeur ou un autre Observableretry(n): retente N fois en cas d’erreur
Pattern d’usage typique
this.searchInput$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.api.search(query).pipe(
catchError(() => of([])),
)),
).subscribe(results => this.results.set(results));
Ce pattern (debounce + switchMap + catchError) est l’un des plus utiles. Difficile à reproduire élégamment sans RxJS.
5. HTTP avec RxJS
HttpClient retourne des Observables.
import { HttpClient } from "@angular/common/http";
@Injectable({ providedIn: "root" })
export class ClientService {
private http = inject(HttpClient);
getClients() {
return this.http.get<Client[]>("/api/clients");
}
searchClients(query: string) {
return this.http.get<Client[]>("/api/clients", {
params: { q: query },
});
}
}
Pourquoi des Observables et pas des Promises ?
- Annulable : si on
unsubscribe, la requête est annulée (utile avecswitchMap) - Composable : on peut chaîner d’autres opérateurs (retry, catchError, transform)
- Multicast possible avec
share
Utilisation dans un composant
@Component({
selector: "app-clients",
standalone: true,
imports: [CommonModule],
template: `
@if (clients()) {
<ul>
@for (c of clients()!; track c.id) {
<li>{{ c.nom }}</li>
}
</ul>
} @else {
<p>Chargement...</p>
}
`,
})
export class ClientsComponent {
private clientService = inject(ClientService);
clients = toSignal(this.clientService.getClients());
}
toSignal convertit l’Observable en Signal directement utilisable dans le template.
6. Interop : toSignal et toObservable
import { toSignal, toObservable } from "@angular/core/rxjs-interop";
// Observable → Signal
const count$ = interval(1000);
const count = toSignal(count$, { initialValue: 0 });
// Signal → Observable
const value = signal(0);
const value$ = toObservable(value);
// Pattern combiné : signal → debounced HTTP → signal
const search = signal("");
const results = toSignal(
toObservable(search).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] },
);
Ce pattern combine la simplicité des signals (state UI) avec la puissance de RxJS (flux asynchrone). Le résultat est un signal final consommable directement dans le template.
takeUntilDestroyed
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@Component(...)
export class MyComponent {
ngOnInit() {
this.someObservable$.pipe(
takeUntilDestroyed(),
).subscribe(...);
}
}
Désabonne automatiquement quand le composant est détruit. Remplace les patterns manuels avec Subject + takeUntil.
7. Pièges classiques
Mutation au lieu de remplacement
// Mauvais : Angular ne détecte pas le changement
const items = signal<Item[]>([]);
items().push(newItem); // mutation, pas de notification
// Bon : nouvelle référence
items.update(list => [...list, newItem]);
Toujours utiliser set() ou update() avec une nouvelle référence.
Effects en cascade
const a = signal(0);
const b = signal(0);
effect(() => {
if (a() > 5) b.set(a() * 2); // change b dans un effect
});
// Possible boucle infinie si b déclenche un autre effect qui change a
Angular permet par défaut, mais rendre les changements explicites améliore la lisibilité. Pour calculer b en fonction de a : utiliser computed() plutôt qu’un effect.
Subscriptions oubliées
// Mauvais : subscription jamais nettoyée
this.observable$.subscribe(...);
// Bon
this.observable$.pipe(takeUntilDestroyed()).subscribe(...);
// ou
const sub = this.observable$.subscribe(...);
ngOnDestroy() { sub.unsubscribe(); }
switchMap vs mergeMap
// Mauvais : si l'utilisateur clique vite, plusieurs requêtes en parallèle, race conditions
this.click$.pipe(
mergeMap(() => this.api.save()),
).subscribe();
// Bon : annule les requêtes précédentes
this.click$.pipe(
switchMap(() => this.api.save()),
).subscribe();
// Mais : pour une opération qu'on ne veut PAS annuler (ex: paiement), exhaustMap
this.click$.pipe(
exhaustMap(() => this.api.pay()),
).subscribe();
8. Performance et change detection
Avec provideZonelessChangeDetection(), Angular ne fait plus de change detection après chaque événement asynchrone, mais uniquement quand un signal change. Performances en hausse, surtout sur des UIs avec beaucoup de timers.
OnPush change detection
Pour les composants qui n’utilisent pas encore de signals :
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
Le composant ne se re-rend que si ses inputs changent (référence) ou si on appelle manuellement markForCheck.
Signal-based avec OnPush
Combinaison optimale : composants avec signals + OnPush. Re-rendus uniquement quand les signals utilisés changent. C’est le défaut idéal pour 2026.
Voir Angular performance optimisation.
9. FAQ
Faut-il migrer tout RxJS vers signals ?
Non. RxJS reste meilleur pour les flux asynchrones. Migrer ce qui est synchrone (BehaviorSubject pour partager un état entre composants → signal). Garder RxJS pour HTTP, événements, flux complexes.
Subject est-il remplacé par signal ?
Pour partager une valeur entre composants : oui, signal est plus simple. Pour émettre des événements ponctuels (clic, message bus) : Subject reste pertinent. Pour un flux observable : Subject ou Observable.
Les inputs en signals sont-ils obligatoires ?
Non, les @Input() classiques fonctionnent encore. Mais input() (signal) est recommandé pour les nouveaux composants : meilleur typage, intégration avec computed(), pas besoin de ngOnChanges pour réagir aux changements.
Comment partager un signal entre composants frères ?
Via un service injecté dans les deux composants. Le service expose un signal. Les deux composants lisent et/ou écrivent. Plus simple qu’un système de communication parent-enfant à plusieurs niveaux.
Le mode zoneless est-il production-ready ?
Oui depuis Angular 18+. À combiner avec des composants entièrement signal-based pour profiter pleinement. Les composants legacy avec @Input classiques peuvent coexister mais ne bénéficient pas autant.
Comment debugger les rendus inattendus ?
Angular DevTools (extension Chrome/Firefox) montre les composants qui rerend, leurs inputs, leur state. Très utile pour identifier les rendus excessifs.
Quand forkJoin plutôt que combineLatest ?
forkJoin : on veut une seule émission, quand toutes les sources sont terminées (équivalent Promise.all). combineLatest : on veut une émission à chaque changement, en gardant la dernière valeur de chaque source. Pour des HTTP isolés : forkJoin. Pour des sources qui continuent d’émettre : combineLatest.
Articles liés (cluster Angular)
- 👉 Angular pour entreprise : guide pratique (pillar)
- 👉 Angular architecture modulaire
- 👉 Angular performance : optimisation pratique
Article mis à jour le 25 avril 2026. Pour signaler une erreur ou suggérer une amélioration, écrivez-nous.