skip to content

In an Angular component, why does calling `takeUntilDestroyed()` inside `ngOnInit` or a click handler throw NG0203, and how do you fix it?

level: middleimportance: should knowfreq 55%

answer

  1. no argument means inject()
  2. injection context has ended
  3. capture DestroyRef early
  4. pass it explicitly
  5. whose DestroyRef is it

basics

~10 s

Angular's takeUntilDestroyed() without an argument injects DestroyRef, which is only allowed in an injection context; ngOnInit and event handlers run later, so it throws NG0203. Inject DestroyRef into a field and pass it in.

solid answer

~30 s

With no argument, `takeUntilDestroyed()` calls `inject(DestroyRef)`, and `inject()` works only in an **injection context**: the constructor, field initializers, DI factories or `runInInjectionContext`. `ngOnInit`, click handlers, subscribe callbacks and service methods called later are outside it, so Angular throws `NG0203` with the message that `takeUntilDestroyed()` can only be used within an injection context. The fix is to capture the `DestroyRef` while the context exists, `private destroyRef = inject(DestroyRef);`, and pass it: `takeUntilDestroyed(this.destroyRef)`. Pass the **component's** `DestroyRef`, not one injected inside a root service, or the subscription lives as long as the app.

code

ts · 19 lines
ts
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
  selector: 'app-poller',
  template: `<button (click)="start()">Start polling</button> {{ ticks() }}`,
})
export class Poller {
  private readonly destroyRef = inject(DestroyRef); // captured in the injection context
  readonly ticks = signal(0);

  start(): void {
    // takeUntilDestroyed() here would throw NG0203; the explicit ref works anywhere.
    interval(1000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.ticks.update(n => n + 1));
  }
}

go deeper

for a junior

Know that the no-argument form belongs in the constructor or a field initializer, and that elsewhere you pass an injected DestroyRef.

for a middle

Explain that the operator injects DestroyRef, list which component code runs inside the injection context, and recognise NG0203.

for a senior

Trace whose DestroyRef a subscription uses and catch root services that attach per-component subscriptions to the application's lifetime.

for a principal

Define ownership rules for subscriptions across services and components so teardown is always owned by the consumer with the shortest lifetime.

## Why the no-argument form is tied to construction `takeUntilDestroyed(destroyRef?)` checks its argument first. With no argument it asserts it is in an injection context (in development mode) and then calls **`inject(DestroyRef)`**. Outside an injection context both fail with the runtime error **`NG0203`**. The development-mode message says that takeUntilDestroyed() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. The injection context itself is a DI topic. For this question you only need its practical edge: | Place in a component | Injection context? | |---|---| | constructor body | yes | | field initializer (`x = inject(Foo)`) | yes | | `ngOnInit`, `ngAfterViewInit` | no | | a `(click)` handler or other template event | no | | a `subscribe` callback or `setTimeout` | no | | a method called from any of the above | no | Many teams subscribe in `ngOnInit` for historical reasons, often because an input value is needed. That is exactly where the no-argument form fails. ## The fix: capture DestroyRef, pass it later 1. Inject `DestroyRef` in a field initializer: `private readonly destroyRef = inject(DestroyRef);` 2. Pass it where the subscription is created: `.pipe(takeUntilDestroyed(this.destroyRef))`. The explicit form performs no injection, so it works in any method at any time. Angular's own guide recommends always passing a `DestroyRef` when code may run outside an injection context. If the subscription is created after the component has already been destroyed (a late async callback, for instance), `takeUntilDestroyed(ref)` completes immediately in v20 and later, because it checks `destroyRef.destroyed` first. ## Whose DestroyRef? Passing a `DestroyRef` also makes **ownership explicit**, and the owner decides when teardown happens: - The **component's** `DestroyRef` fires when that component is destroyed. - A `DestroyRef` injected in a **`providedIn: 'root'` service** is the root environment injector, which is destroyed only with the application. A common senior-level bug is a root service method like `watchSelection()` that subscribes with `takeUntilDestroyed()` in the service constructor or with its own injected `DestroyRef`, called from many components. Every call adds a subscription that outlives the calling component. The fix is to let the **caller** pass its own `DestroyRef`, or to return the Observable and let the component subscribe and tear down. ## Alternatives that avoid the problem - Move the subscription into the **constructor** (or a field initializer) when it needs nothing that only exists later, such as an input value. - Use `runInInjectionContext(injector, () => ...)` with a stored `Injector` when a helper genuinely needs `inject()`; this is heavier and rarely better than passing a `DestroyRef`. - Bind the stream with the **async pipe** or convert it with **`toSignal()`**, which manage the subscription themselves (covered in their own topics). ## What interviewers listen for - The no-argument form **injects**, so it needs an **injection context**; the error is **`NG0203`**. - **Lifecycle hooks and event handlers are not injection contexts.** - The fix is **`inject(DestroyRef)` in a field** and **`takeUntilDestroyed(this.destroyRef)`**. - The `DestroyRef` passed decides the lifetime: a root service's ref means **app lifetime**.

  • A root service exposes `listen(): void` that subscribes with `takeUntilDestroyed()` in the service; what goes wrong when components call it?
    The service's `DestroyRef` is the root injector, so every subscription lives until the application is destroyed. Each component that calls `listen()` adds another permanent subscriber. Accept a `DestroyRef` parameter from the caller, or return the Observable and let each component subscribe with its own teardown.
  • Is `runInInjectionContext` a better fix than passing DestroyRef?
    Usually not. It needs a stored `Injector` and re-enters DI just to fetch one token. Injecting `DestroyRef` once into a field and passing it is simpler, explicit about ownership, and works in any method.

saying these in an interview costs you the question

  • ngOnInit counts as an injection context because the component is fully created
  • The explicit DestroyRef form still requires an injection context
  • Any injected DestroyRef ends the subscription with the calling component
  • NG0203 only appears in production builds
  • The fix is to wrap the call in setTimeout