takeUntilDestroyed
Manual subscription management is one of the most common sources of memory leaks in Angular applications. Forget to unsubscribe from a long-lived observable and it keeps running after the component is gone, holding references and firing callbacks against destroyed views. The takeUntilDestroyed operator, introduced in Angular 16, solves this cleanly: it completes a subscription automatically when the surrounding component, directive, or injection context is destroyed — no ngOnDestroy, no Subscription arrays, no boilerplate.
What takeUntilDestroyed does
takeUntilDestroyed is an RxJS operator exported from @angular/core/rxjs-interop. It wires the observable’s lifecycle to Angular’s DestroyRef. When the associated context is destroyed, the operator emits a completion to the stream, which tears down the subscription the same way takeUntil would with a manual notifier subject.
Because it depends on the framework’s dependency injection, it must be called in an injection context — typically a constructor, a field initializer, or inside a runInInjectionContext callback. When used there, it discovers the current DestroyRef automatically.
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-ticker',
standalone: true,
template: `<p>Ticks: {{ count }}</p>`,
})
export class TickerComponent {
count = 0;
constructor() {
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe(() => this.count++);
}
}
When TickerComponent is removed from the DOM, the interval subscription completes automatically.
Why it replaces manual cleanup
Before takeUntilDestroyed, the idiomatic pattern was a destroy subject:
import { Component, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ /* ... */ })
export class OldTickerComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
interval(1000)
.pipe(takeUntil(this.destroy$))
.subscribe(() => { /* ... */ });
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
The new operator collapses all of that into a single .pipe(takeUntilDestroyed()) call.
| Concern | Manual takeUntil(destroy$) | takeUntilDestroyed() |
|---|---|---|
Extra Subject field | Required | None |
ngOnDestroy boilerplate | Required | None |
| Risk of forgetting cleanup | High | None |
| Works outside components | No | Yes (any DI context) |
| Operator position in pipe | Anywhere | Should be last |
Using it outside an injection context
If you subscribe later — for example, inside an event handler or after ngOnInit has run — you are no longer in an injection context, so the operator cannot resolve DestroyRef on its own. Capture the DestroyRef while you still have access to DI and pass it explicitly.
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { fromEvent } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
template: `<input #box placeholder="Search…" />`,
})
export class SearchComponent {
private destroyRef = inject(DestroyRef);
startListening(input: HTMLInputElement) {
fromEvent(input, 'input')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((e) => console.log((e.target as HTMLInputElement).value));
}
}
Tip: Inject
DestroyRefas a field at construction time, then reuse it anywhere in the class. Callinginject(DestroyRef)itself only works inside an injection context.
Behavior on completion
takeUntilDestroyed completes the stream — it does not error. Any complete callbacks run, and finalize operators fire, giving you a hook for resource cleanup.
import { Component } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { finalize } from 'rxjs/operators';
@Component({ selector: 'app-demo', standalone: true, template: '' })
export class DemoComponent {
constructor() {
interval(500)
.pipe(
finalize(() => console.log('stream finalized — cleanup here')),
takeUntilDestroyed(),
)
.subscribe((n) => console.log('value', n));
}
}
Output:
value 0
value 1
value 2
stream finalized — cleanup here
The finalize runs the moment the component is destroyed, after the last buffered value.
Operator ordering
Place takeUntilDestroyed as the last operator in the pipe whenever possible. Operators after it will not see the completion in the order you might expect, and putting it last guarantees the entire pipeline shuts down together. The one common exception is pairing it with finalize, where finalize typically goes just before it so its callback still fires on teardown.
Best Practices
- Reach for
takeUntilDestroyed()instead of hand-rolleddestroy$subjects orSubscriptionarrays — it is the modern, leak-proof default. - Prefer the no-argument form inside constructors and field initializers where the injection context is available automatically.
- Capture
inject(DestroyRef)once as a class field and pass it explicitly for subscriptions created after construction. - Keep
takeUntilDestroyedlast in the pipe so the whole stream tears down cleanly. - For one-shot streams like
HttpClientcalls that complete on their own, you usually don’t need it — use it for long-lived sources (interval,fromEvent,BehaviorSubject, route params). - Where the data is for the template only, consider the
asyncpipe or signals first;takeUntilDestroyedshines when you need an imperative subscription with side effects.