skip to content

In an Angular codebase, how does the `takeUntil(this.destroy$)` teardown pattern work, where does it go wrong, and how does `takeUntilDestroyed` replace it?

level: seniorimportance: should knowfreq 50%

answer

  1. a Subject fired in ngOnDestroy
  2. next, not only complete
  3. one notifier, many pipes
  4. operator order still matters
  5. DestroyRef removes the boilerplate

basics

~10 s

In Angular's older pattern a component keeps a private Subject, pipes streams through takeUntil(this.destroy$) and calls destroy$.next() in ngOnDestroy; takeUntilDestroyed does the same through DestroyRef without the Subject or hook.

solid answer

~40 s

Before v16, the standard teardown was a `private destroy$ = new Subject<void>()`, `takeUntil(this.destroy$)` in each pipe, and `ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }`. It works, but it fails in recognisable ways: forgetting `next()` (RxJS's `takeUntil` ignores a notifier that only completes), forgetting to implement `ngOnDestroy` in a subclass or copying the field without the hook, and placing `takeUntil` before `switchMap` or `combineLatest`, which leaves inner subscriptions alive. `takeUntilDestroyed()` (stable since v19) packages the same `takeUntil` with a notifier driven by `DestroyRef.onDestroy`, so there is no Subject and no hook to forget. The placement rule is unchanged: it still goes last in the pipe.

code

ts · 16 lines
ts
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { OrderUpdates } from './order-updates';

@Component({ selector: 'app-orders-page', template: `{{ count() }} orders` })
export class OrdersPage {
  readonly count = signal(0);

  constructor() {
    // Replaces: private destroy$ = new Subject<void>(); takeUntil(this.destroy$);
    // and ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
    inject(OrderUpdates).updates$
      .pipe(takeUntilDestroyed())
      .subscribe(orders => this.count.set(orders.length));
  }
}

go deeper

for a junior

Recognise the destroy$ Subject with takeUntil and ngOnDestroy, and know takeUntilDestroyed is the current replacement.

for a middle

Explain why next() is required, why takeUntil must come last, and how the replacement uses DestroyRef instead of a hook.

for a senior

Review legacy components for complete-only notifiers, missing super calls and misplaced takeUntil, and plan a safe mechanical migration.

for a principal

Decide whether a migration is worth a codebase-wide change, and back it with lint rules so new code cannot reintroduce the fragile pattern.

## The classic pattern For years Angular components ended long-lived subscriptions like this: ```ts export class OrdersPage implements OnDestroy { private readonly destroy$ = new Subject<void>(); constructor() { this.orders.updates$ .pipe(takeUntil(this.destroy$)) .subscribe(o => this.render(o)); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } } ``` One `Subject` acts as a **notifier** for every stream in the component. When Angular calls `ngOnDestroy`, `next()` makes every `takeUntil(this.destroy$)` complete its stream, and `complete()` releases the Subject's own subscribers. ## Where it goes wrong The pattern is correct in principle and fragile in practice: 1. **Only `complete()` is called.** In RxJS 7, `takeUntil` reacts to the notifier's **next** notification; a notifier that completes without emitting is ignored. `this.destroy$.complete()` alone leaves every stream running. 2. **The hook is missing.** The field is copied into a new component but `ngOnDestroy` is not, or a subclass overrides `ngOnDestroy` without calling `super.ngOnDestroy()`. Nothing fires the Subject. 3. **Wrong operator order.** `takeUntil` completes only what is upstream. In `source$.pipe(takeUntil(this.destroy$), switchMap(() => poll$))`, the outer source stops but `switchMap` keeps its current inner `poll$` alive. The same goes for operators that combine other streams after it. 4. **Boilerplate drift.** Every component repeats the same five lines; a base class that hides them tends to accumulate other responsibilities. ## What takeUntilDestroyed changes `takeUntilDestroyed` from `@angular/core/rxjs-interop` (developer preview in v16, stable since v19) is the same idea with Angular supplying the notifier: | Aspect | `takeUntil(this.destroy$)` | `takeUntilDestroyed()` | |---|---|---| | notifier | your `Subject<void>` | an Observable fed by `DestroyRef.onDestroy` | | fired by | your `ngOnDestroy` calling `next()` | Angular destroying the component | | boilerplate | field, hook, two calls | one operator | | outside construction | works anywhere | needs `takeUntilDestroyed(this.destroyRef)` | | placement rule | last in the pipe | last in the pipe | | already destroyed owner | subscribes and never ends | completes immediately (v20+) | What does not change: it is still **`takeUntil` underneath**, so the stream **completes** on destroy and the operator must still come **last**. ## Migrating a component 1. Replace each `takeUntil(this.destroy$)` with `takeUntilDestroyed()` if the subscription is created in the constructor or a field initializer. 2. For subscriptions created in `ngOnInit` or later, add `private readonly destroyRef = inject(DestroyRef);` and use `takeUntilDestroyed(this.destroyRef)`. 3. Delete the `destroy$` field and the `ngOnDestroy` body (remove `OnDestroy` if nothing else uses it). 4. While you are there, move any `takeUntil` that sits before a flattening or combining operator to the **end**. The old pattern is not wrong and remains common in existing code; the point of the migration is removing places where a human can forget a step. ## What interviewers listen for - The mechanics: a **Subject notifier**, `takeUntil`, **`next()` in `ngOnDestroy`**. - The failure modes: **complete-only**, **missing hook**, **operator order**. - That `takeUntilDestroyed` is **the same mechanism** with `DestroyRef` as the notifier source. - That **placement still matters** after the migration.

  • Why does `this.destroy$.complete()` alone not stop streams using takeUntil(this.destroy$)?
    RxJS's `takeUntil` completes its output when the notifier emits a value. A notifier that completes without emitting is ignored, so the streams keep running. `next()` must be called first; `complete()` afterwards only tidies up the Subject.
  • After migrating, what does a subscriber's complete callback see on destroy?
    It runs. `takeUntilDestroyed` is built on `takeUntil`, so destroying the component completes the stream exactly as `destroy$.next()` did. Code in `complete` handlers or `finalize` operators behaves the same before and after the migration.

saying these in an interview costs you the question

  • Calling destroy$.complete() is enough to stop takeUntil
  • takeUntilDestroyed unsubscribes differently from takeUntil, so placement no longer matters
  • The destroy$ pattern cannot work and must be removed
  • Angular calls destroy$.next() automatically if the field is named destroy$
  • A subclass's ngOnDestroy runs the parent's teardown without calling super