skip to content

In Angular, how does `takeUntilDestroyed()` from `@angular/core/rxjs-interop` end a subscription, and where can you call it without an argument?

level: juniorimportance: must knowfreq 75%

answer

  1. an operator, not a subscribe option
  2. DestroyRef onDestroy callback
  3. completes the stream
  4. constructor or field initializer
  5. last in the pipe

basics

~20 s

Angular's takeUntilDestroyed() pipes the source through takeUntil with a notifier fired by a DestroyRef's onDestroy callback, so the stream completes when the component, directive or injector is destroyed; without an argument it must run in an injection context.

solid answer

~40 s

`takeUntilDestroyed()` is an operator from `@angular/core/rxjs-interop`, stable since v19. Called with no argument, it does `inject(DestroyRef)`, so it must run in an **injection context**: a constructor, a field initializer, or code under `runInInjectionContext`. It then wraps the source in `takeUntil` with a notifier that fires from `destroyRef.onDestroy(...)`. When the component or directive is destroyed, the notifier emits, the stream **completes**, and the subscription is released. Its `DestroyRef` argument is how you use it elsewhere. Put it **last** in the `pipe`, right before `subscribe`, so no later operator can keep an inner subscription alive. It replaces the older `destroy$` subject plus `ngOnDestroy` boilerplate.

code

ts · 19 lines
ts
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

@Component({
  selector: 'app-breadcrumbs',
  template: `<nav>{{ lastUrl() }}</nav>`,
})
export class Breadcrumbs {
  readonly lastUrl = signal(''); // a signal, so the OnPush (v22 default) view updates

  constructor() {
    inject(Router).events.pipe(
      filter((e): e is NavigationEnd => e instanceof NavigationEnd),
      takeUntilDestroyed(), // injection context: no argument needed; last before subscribe
    ).subscribe(e => this.lastUrl.set(e.urlAfterRedirects));
  }
}

go deeper

for a junior

Recall the import path, that the no-argument form belongs in the constructor or a field initializer, and that it ends the subscription when the component is destroyed.

for a middle

Explain the takeUntil-plus-DestroyRef mechanism, that it completes rather than silently unsubscribes, and why it goes last in the pipe.

for a senior

Know what DestroyRef means in components versus root services, and spot operators after it that keep inner subscriptions alive.

for a principal

Set the codebase's teardown convention across takeUntilDestroyed, the async pipe and toSignal, and decide whether a lint rule should enforce it.

## What problem it solves A component that calls `subscribe()` on a long-lived Observable, such as `Router.events`, a shared service stream or an RxJS `interval`, must end that subscription when the component goes away. Otherwise the source keeps a reference to the callback, the callback keeps a reference to the component, and the callback keeps running for a component that is no longer on screen. Angular's answer is **`takeUntilDestroyed`**, exported from **`@angular/core/rxjs-interop`**. It was added in v16 as a developer preview and **promoted to stable in v19**. ## How it works The implementation is short, and knowing it answers most follow-up questions: 1. If no `DestroyRef` is passed, it calls `inject(DestroyRef)`. In development mode it first asserts that it is in an injection context. 2. It builds a small **notifier Observable** that registers `subscriber.next` with `destroyRef.onDestroy(...)` and returns the unregister function, so an early unsubscribe also removes the destroy callback. 3. It returns `source => source.pipe(takeUntil(notifier))`. When the owner is destroyed, the `onDestroy` callback fires, the notifier emits, and `takeUntil` **completes** the stream. Completion unsubscribes from the source. A subscriber's `complete` callback therefore runs on destroy, which is sometimes a surprise. Since v20, if the `DestroyRef` is **already destroyed** when the stream is subscribed, the notifier checks `destroyRef.destroyed`, emits immediately, and the stream completes at once. ## What DestroyRef represents `DestroyRef` is an injectable token whose meaning depends on where it is injected: | Injected in | Callbacks run when | |---|---| | a component or directive | that component or directive is destroyed | | a service provided in an environment injector (for example `providedIn: 'root'`) | that injector is destroyed, which for root means the application | This is why the operator gives per-screen cleanup in components and directives but not inside a root service: the root injector lives as long as the app. ## Where the no-argument form works An **injection context** is the window in which `inject()` is allowed: - the constructor of a component, directive, pipe or service; - a field initializer of such a class; - a factory function run by DI, or a function passed to `runInInjectionContext`. `ngOnInit`, event handlers, `setTimeout` callbacks and methods called later are **not** injection contexts. There you inject `DestroyRef` once into a field and pass it: `takeUntilDestroyed(this.destroyRef)`. ## Placement in the pipe `takeUntilDestroyed` completes whatever is **upstream** of it. Operators placed **after** it can still hold subscriptions of their own. For example, `switchMap` only completes when both its source and its current inner Observable have completed, so `takeUntilDestroyed()` placed before a `switchMap` to an endless inner stream leaves that inner stream running. The rule of thumb is simple: - put `takeUntilDestroyed(...)` as the **last operator** before `subscribe()`. ## When you do not need it - A stream bound in the template with the **async pipe** is unsubscribed by the pipe. - A stream converted with **`toSignal()`** is cleaned up with its injection context. - A request that **completes on its own**, such as an `HttpClient` call, ends without help, although tying it to `DestroyRef` still stops its callback running after the component is gone. ## What interviewers listen for - It **completes** the stream via `takeUntil` driven by `DestroyRef.onDestroy`. - No-argument calls need an **injection context**; elsewhere pass a **`DestroyRef`**. - `DestroyRef` means **component or directive destroy** in a component, **injector destroy** in a root service. - It belongs **last** in the pipe.

  • Does takeUntilDestroyed unsubscribe silently or complete the stream?
    It completes it. It is built on `takeUntil`, so when the `DestroyRef` callback fires, the subscriber's `complete` handler runs and the source subscription is released. Code in a `complete` callback or a `finalize` operator therefore runs on destroy.
  • What happens if you subscribe with takeUntilDestroyed(ref) after that DestroyRef was already destroyed?
    Since v20 the notifier checks `destroyRef.destroyed` and emits immediately, so the stream completes at once and a late subscription cannot outlive its owner.
  • Why is takeUntilDestroyed() in a providedIn: 'root' service rarely useful?
    In a root service `inject(DestroyRef)` returns the root environment injector, whose destroy callbacks only run when the application is destroyed. The subscription therefore lives for the whole app, which is correct for app-wide state but does not clean up per-screen work.

It is like a hotel key card programmed to stop working at checkout: you do not have to remember to hand it back, because the checkout event (DestroyRef) switches it off for you.

saying these in an interview costs you the question

  • takeUntilDestroyed() can be called anywhere, including ngOnInit, without an argument
  • takeUntilDestroyed unsubscribes without completing the stream
  • The position of takeUntilDestroyed in the pipe does not matter
  • takeUntilDestroyed in a root service ends the subscription when the calling component is destroyed
  • takeUntilDestroyed is still a developer-preview API