In an Angular component, why does calling `takeUntilDestroyed()` inside `ngOnInit` or a click handler throw NG0203, and how do you fix it?
answer
- no argument means inject()
- injection context has ended
- capture DestroyRef early
- pass it explicitly
- whose DestroyRef is it
basics
~10 sAngular'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 sWith 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 linesimport { 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
Know that the no-argument form belongs in the constructor or a field initializer, and that elsewhere you pass an injected DestroyRef.
Explain that the operator injects DestroyRef, list which component code runs inside the injection context, and recognise NG0203.
Trace whose DestroyRef a subscription uses and catch root services that attach per-component subscriptions to the application's lifetime.
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