📧 Reste informé(e) !

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

S'inscrire gratuitement

~5 min de lecture

takeUntilDestroyed : arrête de gérer tes subscriptions à la main

Il y a un boilerplate que tout dev Angular a copié-collé des dizaines de fois. Tu le reconnais immédiatement :

private destroy$ = new Subject<void>();

ngOnInit() {
  this.dataService.getItems()
    .pipe(takeUntil(this.destroy$))
    .subscribe(items => this.items = items);
}

ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

Trois blocs de code distincts. Deux méthodes de lifecycle. Un Subject qui ne sert qu'à mourir proprement. Et si tu oublies le complete(), tu as un memory leak potentiel. Si tu oublies le ngOnDestroy tout court, pareil.

Ce pattern a rendu service pendant des années. Mais depuis Angular 16, il est officiellement obsolète — pas supprimé, mais remplacé par quelque chose de bien mieux.

Le vrai problème des subscriptions non nettoyées

Avant d'aller plus loin, rappel rapide de ce qui se passe si tu ne nettoies pas : une subscription RxJS maintient une référence vers son Observable et vers son callback. Si le composant est détruit mais que l'Observable émet encore (un interval(), un WebSocket, un polling), le callback s'exécute quand même. Le composant ne peut pas être garbage collecté. Tu accumules des callbacks qui tournent dans le vide, et dans les applis avec beaucoup de navigation, ça se ressent.

Les Signals ont atténué le problème pour les données réactives (un computed() ou un effect() se nettoient automatiquement avec leur contexte). Mais dès que tu touches à RxJS — et tu y touches encore, ne te mens pas — le problème reste entier.

takeUntilDestroyed() : la solution en une ligne

Depuis Angular 16, @angular/core/rxjs-interop exporte un opérateur takeUntilDestroyed(). Son comportement : il complète automatiquement la subscription quand le contexte d'injection courant est détruit.

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Component, inject } from '@angular/core';
import { DataService } from './data.service';

@Component({
  selector: 'app-items',
  template: `@for (item of items; track item.id) { <p>{{ item.name }}</p> }`,
  standalone: true,
})
export class ItemsComponent {
  private readonly dataService = inject(DataService);
  protected items: Item[] = [];

  constructor() {
    this.dataService.getItems()
      .pipe(takeUntilDestroyed())
      .subscribe(items => this.items = items);
  }
}

Pas de Subject. Pas de ngOnDestroy. Pas de next() + complete() à ne pas oublier. L'opérateur récupère automatiquement le DestroyRef du composant via inject(), et complète la subscription quand Angular détruit le composant.

Avant / Après

Avant (Angular 15 et antérieur) :

@Component({ selector: 'app-search', standalone: true, template: '...' })
export class SearchComponent implements OnInit, OnDestroy {
  private readonly searchService = inject(SearchService);
  private readonly destroy$ = new Subject<void>();
  protected results: SearchResult[] = [];

  ngOnInit() {
    this.searchService.results$
      .pipe(
        debounceTime(300),
        takeUntil(this.destroy$)
      )
      .subscribe(results => this.results = results);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Après (Angular 16+) :

@Component({ selector: 'app-search', standalone: true, template: '...' })
export class SearchComponent {
  private readonly searchService = inject(SearchService);
  protected results: SearchResult[] = [];

  constructor() {
    this.searchService.results$
      .pipe(
        debounceTime(300),
        takeUntilDestroyed()
      )
      .subscribe(results => this.results = results);
  }
}

Même comportement. Moins de surface d'erreur.

Utilisation dans le constructeur

Une subtilité critique : takeUntilDestroyed() sans argument appelle inject(DestroyRef) en interne. Il doit donc être exécuté dans un contexte d'injection actif — c'est-à-dire pendant la construction de la classe : dans le constructeur ou dans un initialiseur de champ. ngOnInit() n'est pas un contexte d'injection : l'appeler là-dedans lève une erreur NG0203 à runtime.

Le constructeur est donc l'endroit naturel pour les Observables qui démarrent immédiatement :

@Component({ selector: 'app-dashboard', standalone: true, template: '...' })
export class DashboardComponent {
  private readonly statsService = inject(StatsService);
  protected readonly stats = signal<Stats | null>(null);

  constructor() {
    this.statsService.stats$
      .pipe(takeUntilDestroyed())
      .subscribe(stats => this.stats.set(stats));
  }
}

C'est la forme la plus concise. Et elle fonctionne parfaitement avec les Signals — tu abonnes un Observable et tu pousse les valeurs dans un signal manuellement. C'est d'ailleurs exactement ce que fait toSignal() en interne (avec quelques guardrails en plus).

DestroyRef : l'ingrédient secret pour sortir du composant

takeUntilDestroyed() fonctionne parce qu'il utilise DestroyRef en interne. Et tu peux injecter DestroyRef directement pour des cas plus avancés — notamment dans les services ou les fonctions utilitaires.

Dans un service

Un service providedIn: 'root' vit aussi longtemps que l'application : inutile de nettoyer. Mais un service scopé à un composant (fourni via providers: []) ou à une route est détruit avec son contexte. Tu peux injecter DestroyRef pour gérer les subscriptions proprement :

import { Injectable, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Injectable()
export class PollingService {
  private readonly destroyRef = inject(DestroyRef);

  startPolling(callback: (tick: number) => void) {
    interval(5000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(callback);
  }
}

Remarque la signature : takeUntilDestroyed(this.destroyRef). Quand tu passes un DestroyRef explicitement, l'opérateur n'a plus besoin d'être dans un contexte d'injection actif. Tu peux l'utiliser depuis n'importe quelle méthode appelée après le constructeur.

Dans une fonction utilitaire hors composant

C'est là que DestroyRef devient vraiment puissant. Imagine une fonction qui configure un Observable et qui doit se nettoyer automatiquement sans que l'appelant ait à y penser :

import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';

export function autoSubscribe<T>(
  source$: Observable<T>,
  callback: (value: T) => void,
  destroyRef = inject(DestroyRef)
): void {
  source$
    .pipe(takeUntilDestroyed(destroyRef))
    .subscribe(callback);
}

Cette fonction peut être appelée dans n'importe quel contexte d'injection (constructeur de composant, d'une directive, d'un service). Le DestroyRef est capturé au moment de l'appel, et le nettoyage est automatique.

Le piège du contexte d'injection perdu

Le principal gotcha : si tu appelles takeUntilDestroyed() sans argument hors d'un contexte d'injection actif, Angular lève une erreur à runtime :

NG0203: takeUntilDestroyed() can only be used within an injection context

Les cas typiques où tu tombes dans ce piège :

// ❌ Appelé depuis un event handler — pas de contexte d'injection
onButtonClick() {
  this.someService.getData()
    .pipe(takeUntilDestroyed()) // 💥 NG0203
    .subscribe(data => this.data = data);
}

// ❌ Callback asynchrone — le contexte d'injection du constructeur est déjà terminé
constructor() {
  setTimeout(() => {
    this.someService.getData()
      .pipe(takeUntilDestroyed()) // 💥 NG0203
      .subscribe(data => this.data = data);
  }, 1000);
}

La solution : capture DestroyRef dans le constructeur et passe-le explicitement :

export class MyComponent {
  private readonly destroyRef = inject(DestroyRef);

  protected onButtonClick() {
    this.someService.getData()
      .pipe(takeUntilDestroyed(this.destroyRef)) // ✅
      .subscribe(data => this.data = data);
  }
}

C'est la bonne pratique générale : injecte DestroyRef dans le constructeur si tu as besoin de te référer à lui hors contexte d'injection.

DestroyRef.onDestroy() pour les cas non-RxJS

DestroyRef ne se limite pas à RxJS. Il expose une méthode onDestroy() qui enregistre un callback exécuté à la destruction du contexte. Utile pour nettoyer des listeners natifs, des timers, ou n'importe quelle ressource externe :

@Component({ selector: 'app-canvas', standalone: true, template: '<canvas #canvas></canvas>' })
export class CanvasComponent {
  private readonly destroyRef = inject(DestroyRef);
  private readonly elementRef = inject(ElementRef);

  ngAfterViewInit() {
    const resizeObserver = new ResizeObserver(entries => {
      this.onResize(entries[0].contentRect);
    });

    resizeObserver.observe(this.elementRef.nativeElement);

    this.destroyRef.onDestroy(() => resizeObserver.disconnect());
  }

  private onResize(rect: DOMRectReadOnly) {
    // redraw canvas...
  }
}

Propre, explicite, sans ngOnDestroy.

Quid de toSignal() ?

Si ton seul objectif est d'exposer un Observable en Signal dans un template, toSignal() est une alternative encore plus courte qui gère le nettoyage en interne :

// Au lieu de :
protected readonly items = signal<Item[]>([]);

constructor() {
  this.dataService.getItems()
    .pipe(takeUntilDestroyed())
    .subscribe(items => this.items.set(items));
}

// Tu peux écrire :
protected readonly items = toSignal(inject(DataService).getItems(), { initialValue: [] });

toSignal() utilise lui aussi DestroyRef en interne. Mais il te force à exposer le résultat comme un Signal — si tu as besoin de la valeur dans un Observable ou d'accéder à l'abonnement, takeUntilDestroyed() reste pertinent.

Récap actionnable

  • Remplace tout pattern Subject + takeUntil + ngOnDestroy par takeUntilDestroyed()
  • Utilise-le sans argument uniquement dans le constructeur ou les initialiseurs de champs — ngOnInit n'est pas un contexte d'injection
  • Injecte DestroyRef explicitement si tu en as besoin hors du constructeur (event handlers, callbacks asynchrones)
  • Dans les services scopés (fournis via providers), injecte DestroyRef — ne pars pas du principe que le service vit indéfiniment
  • Pour les ressources non-RxJS (observers natifs, timers), utilise destroyRef.onDestroy() plutôt que ngOnDestroy
  • Si tu veux juste un Signal depuis un Observable, préfère toSignal() — c'est encore plus court

Le code le plus fiable est celui qui ne peut pas oublier de se nettoyer. takeUntilDestroyed() et DestroyRef t'y amènent sans cérémonie.

📧 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.