siliceum

Angular et RxJS : éviter les fuites mémoire et maîtriser la réactivité

Quand j’ai découvert RxJS il y a quelques années avec Angular, je voyais juste des .subscribe() partout sur des trucs qu’on appelait des observables.

Mais ce que je ne voyais pas encore, c’étaient les fuites mémoire, les comportements bizarres, et les chaînes d’opérateurs bancales.

Avec le temps, j’ai compris que la programmation réactive, c’est bien plus qu’un outil pour gérer des appels API. C’est un changement de paradigme, qui implique aussi de bonnes pratiques et un modèle mental différent.

Mais avant tout, c’est quoi un Observable ?

Un observable est un flux de données asynchrone que l’on peut écouter dans le temps.

Il est au coeur de la programmation réactive avec RxJS.

L’idée de base : une source qui émet des valeurs

Imaginez un tuyau dans lequel des données arrivent au fil du temps :

const obs$ = new Observable(observer => {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
});

Ici, on crée un flux qui émet 1, 2, 3, puis s’arrête. Pour “écouter” ce flux, on utilise subscribe() :

obs$.subscribe(value => console.log(value));
// affiche : 1, 2, 3

Différence avec une Promise

FeaturePromiseObservable
ValeursUne seulePlusieurs dans le temps
AnnulableNonOui
Lazy (sur demande)OuiOui
Opérateurs.then(), .catch().pipe(), map(), filter()
ExécutionDès créationAu moment du subscribe()

Exemple concret : une recherche utilisateur

searchInput$.pipe(
  debounceTime(300),
  switchMap(query => this.api.search(query))
).subscribe(results => {
  this.results = results;
});
  • searchInput$ est un observable d’événements clavier : on récupère les modifications du champ “Rechercher un utilisateur”
  • debounceTime(300) : attend une pause de 300ms : on veut éviter de lancer une recherche à chaque fois qu’un utilisateur appuie sur une touche. Le debounce ici attend 300ms après la dernière valeur émise pour continuer le flux
  • switchMap(...) : on vient switcher d’observable dans le flux : this.api.search(query) va instancier un nouvel observable (une requête http) qu’on a très envie d’attendre pour récupérer son résultat. On switch donc de flux
  • subscribe(...) : réceptionne les résultats et met à jour l’UI (en vrai, si vous faîtes du Angular, je préfère le pipe | async)

Un observable peut :

  • émettre 0, 1 ou plusieurs valeurs
  • être infini (par exemple : fromEvent, interval)
  • être froid ou chaud (voir la section suivante)
  • être combiné, transformé, filtré, temporisé, réessayé, etc.

RxJS fournit plus de 60 opérateurs pour composer des flux de manière expressive et lisible.

Observable froid vs chaud : comprendre la différence

Observable froid

Un observable est dit “froid” quand la source est réévaluée à chaque abonnement. Cela signifie que chaque souscripteur a son propre cycle de vie.

const cold$ = new Observable(observer => {
  console.log('Nouvel abonné');
  observer.next(Math.random());
});

cold$.subscribe(val => console.log('Abonné 1 :', val));
cold$.subscribe(val => console.log('Abonné 2 :', val));
Nouvel abonné
Abonné 1 : 0.42
Nouvel abonné
Abonné 2 : 0.88
Typiquement : http.get(), of(), from()

Observable chaud

Un observable est dit “chaud” quand il partage sa source de données entre les abonnés.

const subject = new Subject();

subject.subscribe(val => console.log('Abonné 1 :', val));
subject.next(1);
subject.next(2);

subject.subscribe(val => console.log('Abonné 2 :', val));
subject.next(3);
Abonné 1 : 1
Abonné 1 : 2
Abonné 1 : 3
Abonné 2 : 3
Typiquement : Subject, fromEvent, WebSocket, etc.

Froid + partage = chaud (avec shareReplay)

Il est possible de rendre un observable froid “chaud” en le partageant avec share() ou shareReplay() :

const api$ = this.http.get('/data').pipe(shareReplay(1));

Cela évite de déclencher plusieurs requêtes HTTP si plusieurs abonnés s’inscrivent.

Les bonnes pratiques à adopter

Un grand pouvoir implique de grandes responsabilités, comme dirait l’oncle.

1. Toujours gérer la désinscription

Un subscribe() sans unsubscribe() = fuite mémoire assurée. Par défaut les souscriptions aux observables n’entraînent pas de désinscription automatique (comme les évènements JS en fait).

Voici ce qu’il faut faire :

  • Utiliser takeUntil() ou take(1) : l’opérateur vient compléter l’observable automatiquement

    • takeUntil(notifer$)

    Il fonctionne en écoutant un autre observable notifier$ qui, lorsqu’il émet, provoque la complétion (et donc la désinscription) de la source observable.

    Il ne déclenche pas l’unsubscribe tant que ce notifier n’émet pas, mais une fois que c’est fait, la désinscription est automatique.

    • take(1) complète automatiquement l’observable après la première émission, ce qui entraîne un unsubscribe automatique pour cet abonnement.
  • Désinscrire unsubscribe() dans ngOnDestroy de votre composant

  • Utiliser async pipe dans les templates Angular : Le async pipe s’occupe de tout : il souscrit ET désinscrit automatiquement.

<div *ngIf="data$ | async as data">
  {{ data.title }}
</div>

2. Éviter les souscriptions imbriquées

Ne faites surtout pas ceci :

this.a$.subscribe(a => {
  this.b$.subscribe(b => {
    // ...
  });
});

Utilisez switchMap, mergeMap, concatMap, selon le comportement voulu :

this.result$ = this.a$.pipe(
  switchMap(a => this.b$)
);

Pourquoi ?

  • Parce qu’il faut trois boîtes d’Aspirine pour lire une pyramide de la mort (tu sais, quand tu as un paquet de subscribe() imbriqués…)
  • La gestion des erreurs devient compliquée : chaque subscribe() gère ses propres erreurs. L’imbrication = acrobaties assurées pour propager ou centraliser les erreurs correctement
  • Fuites mémoires garanties
  • Ordre d’exécution non maîtrisé

3. Choisir le bon opérateur

Il en existe un bon gros paquet, alors n’hésitons pas à prendre le plus pertinent ! Quelques-uns que j’utilise le plus :

  • switchMap() : annule l’observable précédent à chaque nouvelle émission

    Utile pour des recherches utilisateur en temps réel.

  • debounceTime(500) : attend un délai de calme avant d’émettre

    Utile pour réduire le bruit sur les champs de recherche.

  • map(val => val * 2) : transforme les valeurs

    Manipulation de données simples.

  • throttleTime(2000) : ignore les émissions trop rapprochées

    Anti-spam sur bouton de soumission.

  • shareReplay(1) : partage et met en cache les dernières valeurs

    Pour éviter de rappeler une API 5 fois pour 5 abonnés.

Pour aller plus loin : learnrxjs.io

Les erreurs les plus courantes avec RxJS

1. Oublier de se désinscrire

C’est le piège le plus fréquent. Un subscribe() oublié dans un composant Angular = une souscription qui survit à la destruction du composant, et qui continue d’écouter des événements en arrière-plan.

// A NE PAS FAIRE
ngOnInit() {
  this.dataService.getData().subscribe(data => {
    this.data = data; // cette callback tourne encore après ngOnDestroy
  });
}

2. Souscrire dans un subscribe

Déjà évoqué plus haut, mais c’est tellement courant que ça mérite d’être répété. L’imbrication de subscribe() est la source principale de code illisible et de fuites mémoire en Angular.

3. Confondre switchMap et mergeMap

Utiliser mergeMap là où switchMap est attendu (typiquement une recherche) signifie que les requêtes précédentes ne sont pas annulées, et les résultats arrivent dans le désordre.

4. Ne pas gérer les erreurs dans le pipe

Un catchError manquant dans un pipe peut tuer silencieusement le flux entier. Toujours prévoir un fallback :

this.data$ = this.http.get('/api/data').pipe(
  catchError(err => {
    console.error('Erreur API:', err);
    return of([]); // fallback: tableau vide
  })
);

5. Abuser de shareReplay sans limiter

shareReplay() sans paramètre garde tout en mémoire. Préférez shareReplay(1) pour ne garder que la dernière valeur, et shareReplay({ bufferSize: 1, refCount: true }) pour libérer la souscription source quand plus personne n’écoute.

Guide de choix des opérateurs de “flattening”

OpérateurComportementCas d’usage typique
switchMapAnnule l’observable précédent à chaque nouvelle émissionRecherche en temps réel, autocomplétion
mergeMapExécute tous les observables en parallèleEnvoi de logs, actions indépendantes
concatMapExécute les observables dans l’ordre, un par unFile d’attente de requêtes, opérations séquentielles
exhaustMapIgnore les nouvelles émissions tant que la précédente n’est pas terminéeSoumission de formulaire (anti double-clic)

En cas de doute, commencez par switchMap. C’est le plus sûr dans la majorité des cas (recherche, navigation, filtres). N’utilisez mergeMap que si vous avez vraiment besoin que toutes les requêtes s’exécutent en parallèle.

Tester ses observables : le marble testing

RxJS fournit un mécanisme de test appelé marble testing qui permet de décrire des flux de manière visuelle :

import { TestScheduler } from 'rxjs/testing';

const scheduler = new TestScheduler((actual, expected) => {
  expect(actual).toEqual(expected);
});

scheduler.run(({ cold, expectObservable }) => {
  const source$ = cold('  -a-b-c|');
  const expected = '      -A-B-C|';

  const result$ = source$.pipe(map(v => v.toUpperCase()));
  expectObservable(result$).toBe(expected);
});

Chaque caractère représente une unité de temps (frame) : - est un tick vide, une lettre est une émission, | est la complétion, # est une erreur. C’est un outil puissant pour tester des flux complexes (debounce, switchMap, retry…) de manière déterministe.

Attention à penser à long terme

RxJS est un outil extrêmement puissant, mais sans bonnes pratiques, il peut rapidement devenir difficile à gérer.

Avant de plonger dans le code, prenez le temps de réfléchir à :

  • La “température” de vos observables : distinguez bien les observables “froids” (qui produisent des données à chaque abonnement) des “chauds” (qui émettent indépendamment des abonnés).
  • Qui s’abonne, quand et pourquoi : comprenez bien le cycle de vie de vos abonnements pour éviter les fuites mémoire.
  • Comment gérer l’annulation : prévoyez toujours comment et quand vos abonnements doivent être nettoyés (désabonnés).
  • La robustesse de vos flux : imaginez vos chaînes d’opérateurs capables de gérer les erreurs, les interruptions, et même d’évoluer sans casser l’application (par exemple avec des rollbacks logiques).

Enfin, testez vos flux RxJS indépendamment des composants pour garantir leur fiabilité et faciliter la maintenance.

En résumé

RxJS n’est pas juste un outil pour “faire des appels API” ou “écouter des événements”.

C’est une manière structurée et déclarative de penser la gestion des flux de données asynchrones.

Et comme tout outil puissant mal utilisé… ça peut devenir un cauchemar.

Et vous, quels sont vos opérateurs RxJS préférés ?

Photo de Mickaël  JACQUOT

Écrit par

Mickaël JACQUOT

Développeur FullStack