~7 min de lecture
BehaviorSubject vers signals : 4 patterns à migrer
Ouvre un service Angular d'une app pré-v17. Compte les BehaviorSubject. Si tu en trouves plus de trois, il y a de bonnes chances qu'au moins deux d'entre eux soient devenus du legacy le jour où les signals sont passés stables.
Le problème, c'est que personne ne migre. On garde par habitude, par peur de casser un truc, ou parce qu'on a peur de devoir réécrire tous les composants consommateurs. Et on continue d'écrire des nouveaux services avec le même pattern, parce que c'est ce qu'il y a partout dans le codebase.
Ce guide te donne 4 patterns concrets pour basculer en signals, sans toucher aux composants consommateurs si tu ne veux pas. Et surtout, il te dit quand garder un BehaviorSubject parce que les signals ne peuvent pas faire le job.
Valide à partir d'Angular 17 (signals stables). Les exemples utilisent l'API moderne :
signal(),computed(),inject(),toObservable().
Pourquoi tu devrais migrer
Avant les patterns, le pitch en trois lignes :
- Tu réduis ton bundle. RxJS reste tree-shakable, mais chaque service qui n'importe plus
BehaviorSubject,map,combineLatestetshareReplayretire 2 à 4 Ko de ton chunk principal. Sur un service partagé, ça se compte vite en dizaines de Ko. - Tu retires un état parallèle. Un
BehaviorSubjectqui sert juste à stocker une valeur, c'est l'état mutable de ton service caché dans un mécanisme push. Unsignal()rend cet état explicite, typé, et lisible dans le DevTools. - Tu réduis le risque de fuite. Pas de subscription orpheline si le composant lit un signal au lieu de souscrire à un observable. Le compteur de
takeUntilDestroyed()baisse mécaniquement.
Si ces trois points ne suffisent pas, le 4e est plus subtil : tes computed expressions deviennent synchrones et lisibles, là où combineLatest + map te forçait à raisonner en marbles.
Pattern 1 : le state holder à une valeur
Le cas le plus fréquent. Un service expose un état (user courant, préférences, feature flags), avec un setter et un getter en observable. Personne ne fait de combineLatest dessus, personne ne debounce, personne ne switchMap. C'est juste un store à une case.
Avant
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
type User = { id: string; name: string; email: string };
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly userSubject = new BehaviorSubject<User | null>(null);
readonly user$: Observable<User | null> = this.userSubject.asObservable();
setUser(user: User | null): void {
this.userSubject.next(user);
}
getUser(): User | null {
return this.userSubject.getValue();
}
}
Trois APIs publiques pour stocker une valeur, plus la conversion asObservable() pour empêcher un consommateur de .next() dessus. C'est beaucoup de code pour pas grand chose.
Après
import { Injectable, signal } from '@angular/core';
type User = { id: string; name: string; email: string };
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly userSignal = signal<User | null>(null);
readonly user = this.userSignal.asReadonly();
setUser(user: User | null): void {
this.userSignal.set(user);
}
}
asReadonly() joue le même rôle protecteur que asObservable() : le consommateur peut lire, jamais écrire. Le service perd getUser() parce que c'est désormais inutile, n'importe quel appelant fait userStore.user().
Si tu as encore des consommateurs qui s'attendent à un observable (un guard, un interceptor, un combineLatest legacy), expose un pont :
import { toObservable } from '@angular/core/rxjs-interop';
readonly user$ = toObservable(this.user);
Tu peux faire la bascule progressive : tu migres le service, tu gardes user$ comme façade, et tu refacto les consommateurs un par un.
Pattern 2 : la dérivation via combineLatest
Tu as deux BehaviorSubject et tu calcules une troisième valeur à partir des deux. Classique du panier, des filtres, des permissions.
Avant
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, combineLatest, map } from 'rxjs';
type Item = { id: string; price: number; qty: number };
type Discount = { code: string; percent: number } | null;
@Injectable({ providedIn: 'root' })
export class Cart {
private readonly itemsSubject = new BehaviorSubject<Item[]>([]);
private readonly discountSubject = new BehaviorSubject<Discount>(null);
readonly items$ = this.itemsSubject.asObservable();
readonly total$: Observable<number> = combineLatest([
this.itemsSubject,
this.discountSubject,
]).pipe(
map(([items, discount]) => {
const sub = items.reduce((acc, i) => acc + i.price * i.qty, 0);
return discount ? sub * (1 - discount.percent / 100) : sub;
}),
);
addItem(item: Item): void {
this.itemsSubject.next([...this.itemsSubject.getValue(), item]);
}
applyDiscount(discount: Discount): void {
this.discountSubject.next(discount);
}
}
Tu lis ça lentement et tu vois trois bugs potentiels : un combineLatest qui n'émet que quand les deux sources ont émis (heureusement les BehaviorSubject ont une valeur initiale), une recalculation à chaque souscription si tu oublies shareReplay(1), et un getValue() mutable que personne n'a verrouillé.
Après
import { Injectable, computed, signal } from '@angular/core';
type Item = { id: string; price: number; qty: number };
type Discount = { code: string; percent: number } | null;
@Injectable({ providedIn: 'root' })
export class Cart {
private readonly itemsSignal = signal<Item[]>([]);
private readonly discountSignal = signal<Discount>(null);
readonly items = this.itemsSignal.asReadonly();
readonly total = computed(() => {
const sub = this.itemsSignal().reduce((acc, i) => acc + i.price * i.qty, 0);
const discount = this.discountSignal();
return discount ? sub * (1 - discount.percent / 100) : sub;
});
addItem(item: Item): void {
this.itemsSignal.update(items => [...items, item]);
}
applyDiscount(discount: Discount): void {
this.discountSignal.set(discount);
}
}
Le computed() est memoizé par construction. Pas de shareReplay, pas de timing combineLatest, pas de getter mutable. Et update() te donne la valeur précédente directement, plus besoin de getValue() + next().
Ce pattern à lui seul nettoie 80% des stores feature de la moyenne des codebases Angular intermédiaire.
Pattern 3 : le sélecteur sur état imbriqué
Variante du précédent, mais en présence d'un état riche. Tu as un seul BehaviorSubject qui porte un objet complexe, et plusieurs observables dérivés pour ne pas faire passer tout l'objet aux composants.
Avant
import { Injectable } from '@angular/core';
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
type Filters = {
search: string;
category: string | null;
sortBy: 'price' | 'name' | 'date';
showOutOfStock: boolean;
};
const initial: Filters = {
search: '',
category: null,
sortBy: 'date',
showOutOfStock: false,
};
@Injectable({ providedIn: 'root' })
export class FiltersStore {
private readonly state = new BehaviorSubject<Filters>(initial);
readonly search$ = this.state.pipe(
map(s => s.search),
distinctUntilChanged(),
);
readonly category$ = this.state.pipe(
map(s => s.category),
distinctUntilChanged(),
);
readonly hasActiveFilters$ = this.state.pipe(
map(s => s.search !== '' || s.category !== null || s.showOutOfStock),
distinctUntilChanged(),
);
patch(partial: Partial<Filters>): void {
this.state.next({ ...this.state.getValue(), ...partial });
}
}
Chaque sélecteur est un pipe avec distinctUntilChanged(). C'est lourd, c'est répétitif, et tu paies un pipe à chaque souscription.
Après
import { Injectable, computed, signal } from '@angular/core';
type Filters = {
search: string;
category: string | null;
sortBy: 'price' | 'name' | 'date';
showOutOfStock: boolean;
};
const initial: Filters = {
search: '',
category: null,
sortBy: 'date',
showOutOfStock: false,
};
@Injectable({ providedIn: 'root' })
export class FiltersStore {
private readonly state = signal<Filters>(initial);
readonly search = computed(() => this.state().search);
readonly category = computed(() => this.state().category);
readonly hasActiveFilters = computed(() => {
const s = this.state();
return s.search !== '' || s.category !== null || s.showOutOfStock;
});
patch(partial: Partial<Filters>): void {
this.state.update(s => ({ ...s, ...partial }));
}
}
Pas besoin de distinctUntilChanged() : computed() ne notifie ses lecteurs que si la valeur a changé (égalité référentielle par défaut, surchargeable via l'option equal). Tu écris un sélecteur en une ligne, pas trois.
Bonus : si ta valeur computée retourne un objet dont la référence change à chaque recalcul (un objet reconstruit inline), tu passes une fonction equal pour éviter les notifications inutiles :
readonly activeFilters = computed(
() => ({ search: this.state().search, category: this.state().category }),
{ equal: (a, b) => a.search === b.search && a.category === b.category },
);
Là où tu aurais dû passer une fonction custom à distinctUntilChanged() qui s'évalue à chaque émission, le equal de computed est appelé une seule fois par recalcul.
Pattern 4 : la valeur "courante" lue de façon synchrone
C'est le cas qui empêche les gens de migrer. Tu as un service qui doit lire la dernière valeur d'un état pour décider quelque chose (if (this.auth.isLoggedIn$.getValue()) dans un guard, par exemple).
Avec un BehaviorSubject, tu utilises getValue(). C'est moche, mais ça marche. Beaucoup de gens pensent que les signals ne couvrent pas ce cas. Ils le couvrent en mieux.
Avant
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class Auth {
private readonly userSubject = new BehaviorSubject<User | null>(null);
readonly user$ = this.userSubject.asObservable();
isLoggedIn(): boolean {
return this.userSubject.getValue() !== null;
}
}
Après
import { Injectable, computed, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Auth {
private readonly userSignal = signal<User | null>(null);
readonly user = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.userSignal() !== null);
}
L'appelant n'a plus à choisir entre getValue() ou subscribe(). Il lit auth.isLoggedIn() partout, dans un guard, dans un template, dans un interceptor. Et si le service est consommé hors contexte d'injection (factory provider, util pur), tu n'as plus à te poser la question : tu lis la valeur, point.
Petit piège à connaître : si tu lis le signal dans un effect(), tu crées une dépendance réactive. Si tu veux juste une lecture ponctuelle sans réagir aux changements, utilise untracked() :
import { untracked } from '@angular/core';
const wasLoggedIn = untracked(() => auth.isLoggedIn());
C'est l'équivalent moral de getValue(), mais explicite et inspectable.
Les 2 cas où tu dois garder un BehaviorSubject
Tu ne migres pas tout. Deux cas restent légitimes en RxJS.
Cas 1 : tu as besoin du flux des valeurs intermédiaires
Un signal ne notifie que la dernière valeur. Si trois .set() se succèdent dans la même microtâche, ton effect() ou ton computed() ne verra que la troisième. Pour la plupart des UIs c'est désirable, mais pour de l'analytics, de l'audit, ou un système de commande à émission garantie, tu perds des évènements.
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
type Event = { type: string; payload: unknown };
@Injectable({ providedIn: 'root' })
export class Analytics {
private readonly events = new Subject<Event>();
readonly events$ = this.events.asObservable();
track(event: Event): void {
this.events.next(event);
}
}
Note : Subject, pas BehaviorSubject. Tu n'as pas besoin de "dernière valeur" ici. Si tu pensais utiliser un BehaviorSubject parce qu'il rejoue, demande-toi d'abord si tu as vraiment besoin du replay. La plupart du temps, c'est non.
Cas 2 : tu as besoin d'opérateurs RxJS pour orchestrer
switchMap, debounceTime, retry, bufferTime, concatMap. Tu n'auras jamais ça avec les signals seuls. Si ton service fait du debounce sur une recherche, du retry HTTP, ou du throttling sur un scroll, tu restes en RxJS.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class Search {
private readonly http = inject(HttpClient);
private readonly querySubject = new BehaviorSubject('');
readonly results$ = this.querySubject.pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
);
setQuery(q: string): void {
this.querySubject.next(q);
}
}
Ici BehaviorSubject reste pertinent : tu veux pouvoir injecter une valeur initiale et l'utiliser comme pivot d'un pipe RxJS. Si tu voulais quand même exposer le résultat en signal côté composant, tu colles un toSignal() sur results$ et tu obtiens le meilleur des deux mondes.
Récap actionnable
Le tableau de décision, à coller dans ta head :
| Tu as un BehaviorSubject qui... | Tu fais quoi |
|---|---|
| Stocke juste une valeur, sans opérateur derrière | signal() + asReadonly() |
Sert de pivot à un combineLatest ou map |
signal() + computed() |
Expose plusieurs sélecteurs via pipe(map, distinct) |
signal() + computed() |
Est lu en .getValue() pour décider quelque chose |
signal() direct |
Est consommé par switchMap, debounceTime, retry |
Tu le gardes |
| Doit garantir l'émission de chaque valeur intermédiaire | Tu le gardes (et passe en Subject) |
Trois règles pour ne pas te tirer une balle dans le pied pendant la migration :
- Migre service par service, jamais en big bang. Garde une façade
obs$ = toObservable(signal)pendant la transition. - Tu changes le service, pas la signature. Si
Cart.total$devientCart.total, propage juste cette ligne dans les composants. C'est mécanique. - Si tu hésites, fais le test du
combineLatest. Pas decombineLatest, pas deswitchMap, pas dedebounce? Tu migres. Sinon, tu réfléchis.
La migration n'a pas besoin d'être glamour. Elle a juste besoin d'être finie.