📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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 :

  1. Tu réduis ton bundle. RxJS reste tree-shakable, mais chaque service qui n'importe plus BehaviorSubject, map, combineLatest et shareReplay retire 2 à 4 Ko de ton chunk principal. Sur un service partagé, ça se compte vite en dizaines de Ko.
  2. Tu retires un état parallèle. Un BehaviorSubject qui sert juste à stocker une valeur, c'est l'état mutable de ton service caché dans un mécanisme push. Un signal() rend cet état explicite, typé, et lisible dans le DevTools.
  3. 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 :

  1. Migre service par service, jamais en big bang. Garde une façade obs$ = toObservable(signal) pendant la transition.
  2. Tu changes le service, pas la signature. Si Cart.total$ devient Cart.total, propage juste cette ligne dans les composants. C'est mécanique.
  3. Si tu hésites, fais le test du combineLatest. Pas de combineLatest, pas de switchMap, pas de debounce ? Tu migres. Sinon, tu réfléchis.

La migration n'a pas besoin d'être glamour. Elle a juste besoin d'être finie.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.