takeUntilDestroyed: Automatic Unsubscription in Angular
takeUntilDestroyed: Automatic Unsubscription in Angular
Every Angular developer has faced it: a component subscribes to an Observable, forgets to unsubscribe, and suddenly there's a memory leak silently degrading performance. Angular 16 introduced takeUntilDestroyed() — a native solution that eliminates this entire class of bugs.
The Memory Leak Problem
When a component subscribes to an Observable and doesn't unsubscribe, the subscription lives on after the component is destroyed. This causes memory leaks, unexpected behavior, and performance degradation.
// Memory leak waiting to happen
@Component({...})
export class UserProfileComponent implements OnInit {
public ngOnInit(): void {
this.userService.getUser().subscribe(user => {
this.user = user; // Still fires after component destroyed
});
}
}
Navigate away, come back ten times — now you have ten zombie subscriptions all firing callbacks on a destroyed component.
The Old Solutions
Before Angular 16, we had several ways to handle this. None were ideal.
Manual Unsubscription
@Component({...})
export class UserProfileComponent implements OnInit, OnDestroy {
private _subscription!: Subscription;
public ngOnInit(): void {
this._subscription = this._userService.getUser().subscribe(user => {
this.user = user;
});
}
public ngOnDestroy(): void {
this._subscription.unsubscribe();
}
}
Problems: Boilerplate. Easy to forget. Multiple subscriptions need array or Subscription.add().
Subject + takeUntil Pattern
@Component({...})
export class UserProfileComponent implements OnInit, OnDestroy {
private readonly _destroy$ = new Subject<void>();
public ngOnInit(): void {
this._userService.getUser()
.pipe(takeUntil(this._destroy$))
.subscribe(user => this.user = user);
}
public ngOnDestroy(): void {
this._destroy$.next();
this._destroy$.complete();
}
}
Problems: Still boilerplate. Must remember next() AND complete(). Every component needs the same 4 lines.
Third-Party Libraries
// @ngneat/until-destroy
@UntilDestroy()
@Component({...})
export class UserProfileComponent implements OnInit {
public ngOnInit(): void {
this.userService.getUser()
.pipe(untilDestroyed(this))
.subscribe(user => this.user = user);
}
}
Problems: External dependency. Decorator magic. Can break on Angular updates. Extra bundle size.
Enter takeUntilDestroyed
Angular 16 added takeUntilDestroyed() to @angular/core/rxjs-interop. Native. Zero dependencies. Just works.
import { inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class UserProfileComponent {
private readonly _userService = inject(UserService);
protected user: User | null = null;
constructor() {
this._userService.getUser()
.pipe(takeUntilDestroyed())
.subscribe(user => this.user = user);
}
}
No OnDestroy. No Subject. No cleanup code. Angular handles everything.
How It Works
takeUntilDestroyed() internally uses DestroyRef, which Angular creates for every component, directive, service, and pipe. When the component is destroyed, DestroyRef triggers, and the operator completes the Observable.
// What happens under the hood (simplified)
function takeUntilDestroyed(destroyRef = inject(DestroyRef)) {
return <T>(source: Observable<T>) => {
return new Observable<T>(subscriber => {
const subscription = source.subscribe(subscriber);
destroyRef.onDestroy(() => {
subscription.unsubscribe();
});
return subscription;
});
};
}
Basic Usage
In Components
import { inject } from '@angular/core';
@Component({
selector: 'app-dashboard',
template: `<div>{{ data }}</div>`
})
export class DashboardComponent {
protected data = '';
private readonly _dataService = inject(DataService);
constructor() {
this._dataService.getData()
.pipe(takeUntilDestroyed())
.subscribe(data => this.data = data);
}
}
Important: When called without arguments, takeUntilDestroyed() must be used in an injection context — constructor, field initializer, or factory function with inject().
In Services
Works the same way. When the service is destroyed (e.g., a component-provided service), subscriptions clean up automatically.
import { inject } from '@angular/core';
@Injectable()
export class FeatureService {
private readonly _state = signal<Data | null>(null);
private readonly _api = inject(ApiService);
constructor() {
this._api.fetchData()
.pipe(takeUntilDestroyed())
.subscribe(data => this._state.set(data));
}
}
Multiple Subscriptions
No arrays, no tracking — just use it on each Observable:
constructor() {
this._userService.currentUser$
.pipe(takeUntilDestroyed())
.subscribe(user => this.user = user);
this._notificationService.notifications$
.pipe(takeUntilDestroyed())
.subscribe(notifications => this.notifications = notifications);
this._themeService.theme$
.pipe(takeUntilDestroyed())
.subscribe(theme => this._applyTheme(theme));
}
Using DestroyRef Directly
Sometimes you need to subscribe outside the injection context — in ngOnInit, event handlers, or utility functions. Pass DestroyRef explicitly.
In Lifecycle Hooks
import { inject } from '@angular/core';
@Component({...})
export class SearchComponent implements OnInit {
private readonly _destroyRef = inject(DestroyRef);
private readonly _searchService = inject(SearchService);
protected results: Result[] = [];
public ngOnInit(): void {
// Outside injection context — pass _destroyRef
this._searchService.results$
.pipe(takeUntilDestroyed(this._destroyRef))
.subscribe(results => this.results = results);
}
}
In Methods
import { inject } from '@angular/core';
@Component({...})
export class ChartComponent {
private readonly _destroyRef = inject(DestroyRef);
private readonly _chartService = inject(ChartService);
protected onFilterChange(filter: Filter): void {
this._chartService.getData(filter)
.pipe(takeUntilDestroyed(this._destroyRef))
.subscribe(data => this._renderChart(data));
}
private _renderChart(data: ChartData): void {
// ...
}
}
In Utility Functions
This is where takeUntilDestroyed truly shines — reusable functions that respect component lifecycle:
// utils/polling.ts
export function poll<T>(
source: () => Observable<T>,
interval: number,
destroyRef: DestroyRef
): Observable<T> {
return timer(0, interval).pipe(
switchMap(() => source()),
takeUntilDestroyed(destroyRef)
);
}
// Usage in component
import { inject } from '@angular/core';
export class StatusComponent {
private readonly _destroyRef = inject(DestroyRef);
private readonly _api = inject(ApiService);
protected readonly status$ = poll(
() => this._api.getStatus(),
30000,
this._destroyRef
);
}
Real-World Example
Here's a Service Worker update service using takeUntilDestroyed for multiple subscriptions:
import { Injectable, inject, DestroyRef } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { Router, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, switchMap, catchError } from 'rxjs/operators';
import { timer, EMPTY } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UpdateService {
private readonly _swUpdate = inject(SwUpdate);
private readonly _router = inject(Router);
private readonly _destroyRef = inject(DestroyRef);
private _updateAvailable = false;
public checkForUpdates(): void {
if (!this._swUpdate.isEnabled) return;
// Listen for version updates
this._swUpdate.versionUpdates
.pipe(
filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'),
takeUntilDestroyed(this._destroyRef)
)
.subscribe(() => {
this._updateAvailable = true;
this._promptUpdate();
});
// Check periodically
timer(0, 60 * 60 * 1000)
.pipe(
switchMap(() => this._swUpdate.checkForUpdate()),
catchError(() => EMPTY),
takeUntilDestroyed(this._destroyRef)
)
.subscribe();
// Check on navigation
this._router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntilDestroyed(this._destroyRef)
)
.subscribe(() => {
if (this._updateAvailable) {
this._promptUpdate();
}
});
}
private _promptUpdate(): void {
// Show update dialog
}
}
Three subscriptions, zero manual cleanup. When the service is destroyed, all subscriptions complete automatically.
When You Don't Need It
Not every subscription needs takeUntilDestroyed. Skip it when:
HTTP Requests (Single Emission)
HTTP calls complete after one response. No leak possible.
// No takeUntilDestroyed needed
this.http.get('/api/data').subscribe(data => this.process(data));
Exception: If you need to cancel the request when the component is destroyed (e.g., long-running request), then use it.
Async Pipe
The async pipe handles subscription lifecycle automatically:
@Component({
template: `
@for (item of items$ | async; track item.id) {
<div>{{ item.name }}</div>
}
`
})
import { inject } from '@angular/core';
export class ListComponent {
private readonly _itemService = inject(ItemService);
protected readonly items$ = this._itemService.getItems();
}
Signals with toSignal
toSignal manages its own cleanup:
@Component({...})
import { inject } from '@angular/core';
export class UserComponent {
private readonly _userService = inject(UserService);
protected readonly user = toSignal(this._userService.currentUser$);
// Automatically cleaned up
}
Migrating from @ngneat/until-destroy
If you're using @ngneat/until-destroy, migration is straightforward:
Before:
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
@UntilDestroy()
@Component({...})
export class MyComponent implements OnInit {
public ngOnInit(): void {
this.data$.pipe(untilDestroyed(this)).subscribe();
}
}
After:
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
@Component({...})
export class MyComponent {
private readonly _destroyRef = inject(DestroyRef);
public ngOnInit(): void {
this.data$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe();
}
}
Or move to constructor:
@Component({...})
export class MyComponent {
constructor() {
this.data$.pipe(takeUntilDestroyed()).subscribe();
}
}
Migration checklist:
- Remove
@ngneat/until-destroyfrompackage.json - Remove
@UntilDestroy()decorators - Replace
untilDestroyed(this)withtakeUntilDestroyed() - For
ngOnInitsubscriptions: injectDestroyRefand pass it, or move to constructor
Common Mistakes
Using Outside Injection Context
// Error: takeUntilDestroyed() called outside injection context
public ngOnInit(): void {
this.data$.pipe(takeUntilDestroyed()).subscribe(); // Throws!
}
Fix: Inject DestroyRef and pass it:
private readonly _destroyRef = inject(DestroyRef);
public ngOnInit(): void {
this.data$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe();
}
Operator Order Matters
Place takeUntilDestroyed last in the pipe (before subscribe):
// Correct
this.data$.pipe(
filter(x => x > 0),
map(x => x * 2),
takeUntilDestroyed()
).subscribe();
// Works but less efficient — filtering continues after destroy signal
this.data$.pipe(
takeUntilDestroyed(),
filter(x => x > 0),
map(x => x * 2)
).subscribe();
Key Takeaways
- Use
takeUntilDestroyed()for any long-lived subscription in components and services - No arguments in injection context — constructor and field initializers
- Pass
DestroyRefwhen subscribing in lifecycle hooks or methods - Skip it for HTTP requests, async pipe, and
toSignal - Native solution — zero dependencies, maintained by Angular team
Memory leaks from forgotten subscriptions are now a solved problem. One import, one operator, zero boilerplate.