Maîtrisez la migration de RxJS vers les Signals Angular 19+ : équivalences, toSignal, toObservable, pièges réels et architecture hybride efficace en pratique.
Pourquoi migrer (et quand ne pas le faire)
Depuis Angular 17, les Signals sont stables et l'écosystème pousse à les adopter. Mais migrer un projet RxJS existant vers les Signals n'est pas un simple find & replace. Il faut comprendre ce que chaque outil fait bien, et identifier les zones où la migration apporte un vrai bénéfice.
Les Signals brillent pour :
- L'état synchrone local d'un composant (
count,isOpen,filterText) - Les valeurs dérivées (
computed()— équivalent d'unmapsynchrone) - L'intégration avec le change detection (zoneless, performance accrue)
- Le templating direct sans
asyncpipe :{{ count() }}
RxJS reste irremplaçable pour :
- Les flux HTTP avec retry, timeout, cancellation
- Les événements DOM debounceés (
debounceTime,throttleTime) - Les WebSockets et flux temps réel
- La composition asynchrone complexe (
switchMap,mergeMap,concatMap)
toSignal().
La promesse des Signals est double : moins de boilerplate dans les composants, et change detection plus fin. Mais cette promesse ne se concrétise que si la migration respecte la sémantique du code initial. Une BehaviorSubject qui propage des objets mutés cassera silencieusement avec un Signal — et inversement, un computed() qui dépend d'un Observable non converti ne se mettra jamais à jour.
Équivalences RxJS ↔ Signals
Avant de migrer, il faut établir un dictionnaire de traduction. Toutes les primitives RxJS n'ont pas d'équivalent direct, mais les plus courantes oui.
| RxJS | Signal | Notes |
|---|---|---|
BehaviorSubject<T>(initial) |
signal<T>(initial) |
Équivalence directe pour l'état synchrone |
.next(v) |
.set(v) ou .update(fn) |
update pour transformations basées sur la valeur courante |
.value |
signal() |
Lecture par appel de fonction |
combineLatest([a$, b$]).pipe(map(...)) |
computed(() => fn(a(), b())) |
Auto-tracking des dépendances |
tap(v => sideEffect(v)) |
effect(() => sideEffect(sig())) |
Pour effets de bord uniquement |
distinctUntilChanged() |
Intégré (égalité référentielle) | Personnalisable via l'option equal |
shareReplay(1) |
Comportement natif des Signals | Un Signal partage sa valeur à tous ses lecteurs |
Subject (sans valeur initiale) |
Pas d'équivalent direct | Garder RxJS ou modéliser comme événement |
switchMap / mergeMap |
Pas d'équivalent | Garder RxJS pour l'asynchrone |
Exemple concret — un compteur RxJS :
// AVANT — RxJS
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class CounterService {
// Source de vérité avec valeur initiale
private count$ = new BehaviorSubject<number>(0);
// Valeur dérivée : double du compteur
readonly double$ = this.count$.pipe(map(c => c * 2));
// Lecture synchrone (souvent un anti-pattern)
get current(): number { return this.count$.value; }
increment() {
this.count$.next(this.count$.value + 1);
}
}
// APRÈS — Signals
import { Injectable, computed, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
// Source de vérité — appelable comme une fonction
readonly count = signal<number>(0);
// Valeur dérivée — réévaluée automatiquement quand count change
readonly double = computed(() => this.count() * 2);
// Lecture synchrone naturelle
increment() {
// update accepte une fonction qui reçoit la valeur courante
this.count.update(c => c + 1);
}
}
async pipe ni de subscribe. {{ counter.count() }} et {{ counter.double() }} suffisent dans le template — Angular tracke les lectures et déclenche le rendu.
toSignal : convertir un Observable
toSignal() est le pont qui permet d'utiliser un Signal dans le template à partir d'un Observable RxJS. C'est la fonction la plus utile de la migration : elle remplace l'async pipe et permet de combiner facilement état synchrone et flux asynchrone.
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users(); as list) {
<ul>
@for (user of list; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
} @else {
<p>Chargement...</p>
}
`
})
export class UsersComponent {
private http = inject(HttpClient);
// Convertit l'Observable HTTP en Signal
// Sans initialValue, le type est User[] | undefined
readonly users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
}
Options clés de toSignal :
initialValue: valeur synchrone avant la première émission. Sans elle, le Signal peut êtreundefined.requireSync: true: exige une émission synchrone (utile pourBehaviorSubject) — sinon erreur au runtime.injector: pour utilisertoSignalhors d'un contexte d'injection (factories, services lazy).manualCleanup: true: désactive l'unsubscribe automatique (rare, à utiliser avec prudence).
Cas du BehaviorSubject existant — éviter le undefined :
// Service legacy avec BehaviorSubject
private theme$ = new BehaviorSubject<'light' | 'dark'>('light');
// Conversion sans valeur initiale risque undefined transitoirement
readonly theme = toSignal(this.theme$); // 'light' | 'dark' | undefined
// Mieux : requireSync garantit la valeur synchrone
readonly theme = toSignal(this.theme$, { requireSync: true });
// Type : 'light' | 'dark'
requireSync: true, TypeScript élimine undefined du type retourné. C'est crucial pour les Signals lus dans des templates ou passés à d'autres computed.
toObservable : exposer un Signal
L'opération inverse — exposer un Signal sous forme d'Observable — est moins fréquente mais cruciale dans deux cas : interopérabilité avec du code RxJS existant (effets, opérateurs avancés), et déclenchement de flux HTTP en réaction à un changement de Signal.
import { toObservable } from '@angular/core/rxjs-interop';
import { signal, computed } from '@angular/core';
import { switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({ /* ... */ })
export class SearchComponent {
// État UI : terme de recherche
readonly query = signal('');
// Convertir le Signal en Observable pour profiter du debounce RxJS
readonly results = toSignal(
toObservable(this.query).pipe(
// Attendre que l'utilisateur arrête de taper
debounceTime(300),
// Ignorer les recherches identiques consécutives
distinctUntilChanged(),
// Annuler la requête précédente si l'utilisateur retape
switchMap(q => q ? this.http.get<Result[]>(`/api/search?q=${q}`) : of([]))
),
{ initialValue: [] as Result[] }
);
onInput(value: string) {
// Mettre à jour le Signal — déclenche le pipeline
this.query.set(value);
}
}
Ce pattern combine le meilleur des deux mondes : UI pilotée par Signal (input, set), débouncing/cancellation par RxJS, et résultat exposé en Signal pour le template.
toObservable émet la valeur initiale du Signal de façon synchrone, puis chaque mise à jour. Cela ressemble à un BehaviorSubject. Si vous voulez ignorer la valeur initiale, ajoutez .pipe(skip(1)).
Patterns de migration courants
Voici les patterns les plus rencontrés en migration réelle, avec le code avant/après et les pièges spécifiques à chacun.
Pattern 1 — Filtre + tri d'une liste
// AVANT — RxJS
private items$ = new BehaviorSubject<Item[]>([]);
private filter$ = new BehaviorSubject<string>('');
readonly visible$ = combineLatest([this.items$, this.filter$]).pipe(
map(([items, filter]) =>
items.filter(i => i.name.includes(filter))
.sort((a, b) => a.name.localeCompare(b.name))
)
);
// APRÈS — Signals
readonly items = signal<Item[]>([]);
readonly filter = signal('');
readonly visible = computed(() => {
// Lecture des deux Signals — auto-trackée
const f = this.filter().toLowerCase();
return this.items()
.filter(i => i.name.toLowerCase().includes(f))
.sort((a, b) => a.name.localeCompare(b.name));
});
Pattern 2 — Effet de bord (logger, localStorage)
// AVANT — RxJS subscribe + manual cleanup
ngOnInit() {
this.subscription = this.theme$.subscribe(t =>
localStorage.setItem('theme', t)
);
}
ngOnDestroy() { this.subscription?.unsubscribe(); }
// APRÈS — effect()
constructor() {
// Cleanup automatique au destroy du composant
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
Pattern 3 — Form value avec validation dérivée
// AVANT — Reactive Forms + RxJS
this.form.valueChanges.pipe(
map(v => ({ ...v, valid: this.form.valid })),
distinctUntilChanged()
).subscribe(s => this.formStateSubject.next(s));
// APRÈS — toSignal sur form.valueChanges
readonly formValue = toSignal(this.form.valueChanges, {
initialValue: this.form.value
});
readonly formStatus = toSignal(this.form.statusChanges, {
initialValue: this.form.status
});
readonly isValid = computed(() => this.formStatus() === 'VALID');
effect() ou computed() en passant par une variable intermédiaire en dehors de la lecture. const v = this.sig; computed(() => v()) ne tracke pas la dépendance — il faut computed(() => this.sig()).
Les pièges réels en production
Cette section regroupe les bugs subtils rencontrés lors de migrations réelles. Anticipez-les avant qu'ils n'arrivent en prod.
Piège 1 — Mutation d'objet sans set
const user = signal({ name: 'Alice', age: 30 });
// ❌ Mute l'objet sans notifier les lecteurs
user().age = 31;
// ❌ Idem dans un .update qui retourne le même objet
user.update(u => { u.age = 31; return u; });
// ✅ Crée une nouvelle référence
user.update(u => ({ ...u, age: 31 }));
Piège 2 — computed qui ne se met pas à jour
Si un computed dépend d'une valeur qui n'est pas un Signal (variable, propriété sans get), Angular ne peut pas tracker la dépendance.
// ❌ this.config est un objet brut
private config = { multiplier: 2 };
readonly result = computed(() => this.value() * this.config.multiplier);
// Modifier this.config.multiplier ne déclenche aucune réévaluation
// ✅ Convertir config en Signal
private config = signal({ multiplier: 2 });
readonly result = computed(() => this.value() * this.config().multiplier);
Piège 3 — effect qui modifie un Signal qu'il lit
// ❌ Boucle infinie : effect lit count, puis le modifie, ce qui retrigger l'effect
effect(() => {
if (count() < 10) count.update(c => c + 1);
});
Pour modifier un Signal depuis un effect, utilisez l'option allowSignalWrites: true (avec parcimonie) ou — bien mieux — un computed ou un appel direct ailleurs.
Piège 4 — Souscriptions RxJS oubliées après migration partielle
En migrant un BehaviorSubject vers un signal, n'oubliez pas les subscribe() dispersés ailleurs dans le code. Faites une recherche projet sur le nom du sujet avant de le supprimer.
Piège 5 — toSignal sans injector hors composant
// ❌ Erreur : injection context manquant dans une factory
export function createService() {
return toSignal(http.get('/api')); // throws !
}
// ✅ Passer l'injector explicitement
export function createService(injector: Injector) {
return toSignal(http.get('/api'), { injector });
}
Piège 6 — Égalité référentielle des arrays/objets
Par défaut, un Signal compare ses valeurs avec ===. Deux arrays différents avec le même contenu sont considérés différents. Pour éviter des recalculs inutiles dans computed, fournissez une fonction equal :
readonly items = signal<number[]>([], {
equal: (a, b) => a.length === b.length &&
a.every((v, i) => v === b[i])
});
set.
Architecture hybride RxJS + Signals
Le scénario gagnant en 2026 n'est pas tout RxJS ou tout Signal, mais une architecture hybride où chaque outil prend en charge ce qu'il fait le mieux.
Couches recommandées :
- Service de données : RxJS pour HTTP, WebSocket, retry, debounce. Expose un Signal final via
toSignal(). - State management léger : Signals +
computed. NgRx Signal Store si l'état devient complexe. - Composant : 100% Signals dans le template.
toObservableseulement pour les pipelines async dérivés d'inputs.
// Service hybride : RxJS pour la lecture HTTP, Signal pour l'exposition
@Injectable({ providedIn: 'root' })
export class ProductsService {
private http = inject(HttpClient);
// Signal d'entrée : pilote la requête
readonly searchQuery = signal('');
// Pipeline RxJS pour gérer debounce + cancellation
private products$ = toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.http.get<Product[]>(`/api/products?q=${q}`)),
// Cache du dernier résultat pour les nouveaux subscribers
shareReplay({ bufferSize: 1, refCount: true })
);
// Exposition en Signal pour le template
readonly products = toSignal(this.products$, {
initialValue: [] as Product[]
});
// Signal dérivé : nombre de résultats
readonly count = computed(() => this.products().length);
}
Cette architecture donne un service simple à consommer côté composant (products(), count()) tout en conservant la robustesse de RxJS pour la couche réseau.
Stratégie de migration progressive
Ne migrez jamais un projet en une seule PR. Adoptez une approche par couches, en commençant par les zones où le gain est immédiat.
- Étape 1 — Identifier tous les
BehaviorSubjectqui détiennent de l'état UI synchrone (filtres, formulaires, toggles) et les migrer ensignal. - Étape 2 — Remplacer les
combineLatest + mapsur état synchrone parcomputed. - Étape 3 — Remplacer les
asyncpipes des templates partoSignal()dans les composants — gain de change detection immédiat. - Étape 4 — Convertir les
subscribe()avec effet de bord eneffect()(logger, localStorage, telemetry). - Étape 5 — Garder RxJS pour la couche HTTP et les flux d'événements DOM debouncés.
- Étape 6 — Évaluer l'activation de
provideExperimentalZonelessChangeDetectionsur les zones 100% Signals. - Étape 7 — Mesurer : Lighthouse, Angular DevTools, taille du bundle. Comparer avant/après pour valider le gain.
Mesures observées sur projets réels (équipe e-commerce, 80 composants migrés) :
- Réduction du temps de change detection : 30 à 45 % sur les pages filtre/tri
- Réduction des lignes de code dans les composants : 20 % en moyenne (suppression des
subscribe,OnDestroy,asyncpipe) - Réduction des bugs liés aux subscriptions oubliées : quasi nulle après migration
Ressources officielles
Conclusion : adopter le bon dosage
Migrer de RxJS aux Signals n'est pas un remplacement, c'est une spécialisation des responsabilités. Les Signals brillent sur l'état UI synchrone et les valeurs dérivées ; RxJS reste irremplaçable pour les flux asynchrones complexes. La meilleure architecture Angular 19+ combine les deux via toSignal() et toObservable().
À retenir avant de migrer un projet :
- Commencer petit : un composant feuille, un service local — jamais un store global d'un seul coup.
- Conserver RxJS pour HTTP, WebSocket, debounce, retry, combinaisons asynchrones — ne pas le forcer à disparaître.
- Mutations immuables : toujours créer une nouvelle référence (
{...current}) pour notifier les Signals dépendants. - computed() avant effect() : préférer la dérivation déclarative aux effets impératifs pour la UI.
- Tester l'interop :
toSignal({ initialValue })pour éviter les étatsundefineden SSR.
input()/output()/model() et les Signal Queries (viewChild, contentChild) pour compléter votre stack 100% signal-based.