Front-end angularforall.com

- Migrer de RxJS aux Signals : patterns & pièges

Angular Signals Rxjs Tosignal Toobservable Migration Reactive Angular-19 Angular-20 Computed Effect Interop
Migrer de RxJS aux Signals : patterns & pièges

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'un map synchrone)
  • L'intégration avec le change detection (zoneless, performance accrue)
  • Le templating direct sans async pipe : {{ 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)
Règle pragmatique : si une valeur change synchroniquement à cause d'une action utilisateur, utilise un Signal. Si elle dépend du temps, du réseau ou d'événements externes, garde RxJS et expose un Signal en bout de chaîne via 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);
    }
}
Note : dans le composant, plus besoin de 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 être undefined.
  • requireSync: true : exige une émission synchrone (utile pour BehaviorSubject) — sinon erreur au runtime.
  • injector : pour utiliser toSignal hors 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'
Astuce typage : avec 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.

Important : 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');
Anti-pattern fréquent : ne pas appeler un Signal dans 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])
});
Conseil : n'optimisez l'égalité que sur les Signals très lus. Sinon vous payez le coût de la comparaison à chaque 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. toObservable seulement 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 BehaviorSubject qui détiennent de l'état UI synchrone (filtres, formulaires, toggles) et les migrer en signal.
  • Étape 2 — Remplacer les combineLatest + map sur état synchrone par computed.
  • Étape 3 — Remplacer les async pipes des templates par toSignal() dans les composants — gain de change detection immédiat.
  • Étape 4 — Convertir les subscribe() avec effet de bord en effect() (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 provideExperimentalZonelessChangeDetection sur 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, async pipe)
  • Réduction des bugs liés aux subscriptions oubliées : quasi nulle après migration
Mon retour de migration : ne tentez pas de tout migrer. Une codebase hybride bien pensée est plus performante et plus simple qu'une codebase « tout Signals » mal traduite. Le but est de simplifier, pas de cocher une case de modernité.

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 états undefined en SSR.
Pour aller plus loin : consultez la nouvelle API input()/output()/model() et les Signal Queries (viewChild, contentChild) pour compléter votre stack 100% signal-based.

Partager