~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 + ngOnDestroypartakeUntilDestroyed() - Utilise-le sans argument uniquement dans le constructeur ou les initialiseurs de champs —
ngOnInitn'est pas un contexte d'injection - Injecte
DestroyRefexplicitement si tu en as besoin hors du constructeur (event handlers, callbacks asynchrones) - Dans les services scopés (fournis via
providers), injecteDestroyRef— ne pars pas du principe que le service vit indéfiniment - Pour les ressources non-RxJS (observers natifs, timers), utilise
destroyRef.onDestroy()plutôt quengOnDestroy - 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.