skip to content

In Angular, why does calling `toSignal()` in `ngOnInit` throw NG0203, and why does calling it inside `computed()` or a template getter throw NG0602?

level: seniorimportance: should knowfreq 45%

answer

  1. cleanup needs a DestroyRef
  2. injector or manualCleanup options
  3. reactive context means re-evaluation
  4. a new subscription per evaluation
  5. derive the Observable, convert once

basics

~10 s

Angular's toSignal() injects DestroyRef for cleanup, so outside an injection context it throws NG0203; inside a reactive context each re-evaluation would create a new subscription, so development mode throws NG0602.

solid answer

~50 s

By default `toSignal()` needs a `DestroyRef` to register its unsubscribe, and it gets one with `inject()`, so it must run in an **injection context**. In `ngOnInit`, an event handler or a subscribe callback it throws `NG0203`. Fixes: move the call to a field initializer, pass `{ injector }` with an `Injector` captured earlier (cleanup then follows that injector's `DestroyRef`), or pass `{ manualCleanup: true }` for a source that completes on its own. Separately, `toSignal()` refuses to run inside a **reactive context** (a `computed()`, an `effect()` or a template expression): every re-evaluation would subscribe again, so development mode throws `NG0602`. Production skips that assertion, so if an `injector` or `manualCleanup` option also silences `NG0203`, the code silently piles up subscriptions. The fix is to build the Observable from its inputs, for example `toObservable(symbol).pipe(switchMap(...))`, and call `toSignal()` once.

code

ts · 18 lines
ts
import { Component, inject, input } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { PriceFeed } from './price-feed';

@Component({
  selector: 'app-symbol-price',
  template: `{{ quote()?.price ?? '...' }}`,
})
export class SymbolPrice {
  readonly symbol = input.required<string>();
  private readonly feed = inject(PriceFeed);

  // Field initializer: an injection context, and not a reactive context.
  readonly quote = toSignal(
    toObservable(this.symbol).pipe(switchMap(s => this.feed.watch(s))),
  );
}

go deeper

for a junior

Call toSignal once, in a field initializer or constructor, never in lifecycle hooks, getters or computed().

for a middle

Explain the DestroyRef dependency behind NG0203, the injector and manualCleanup options, and why reactive contexts are forbidden.

for a senior

Refactor input-driven toSignal calls into one derived Observable, and catch development-only NG0602 before it becomes a production subscription leak.

for a principal

Establish conventions and lint checks for where stream-to-signal conversion lives, so components never create subscriptions during rendering.

## Two different rules, two different errors `toSignal()` has two placement rules, and interviewers like to see whether a candidate can tell them apart. | Rule | Error | Where it bites | |---|---|---| | needs an **injection context** (for its `DestroyRef`) unless told otherwise | `NG0203` | `ngOnInit`, event handlers, `subscribe` callbacks, methods called later | | must **not** run in a **reactive context** | `NG0602` (development mode) | inside `computed()`, `effect()`, or a template expression or getter | ## Rule 1: the injection context By default `toSignal()` registers its unsubscribe with a `DestroyRef`. With no options it obtains one via `inject(DestroyRef)`, which only works in an injection context: a constructor, a field initializer, a DI factory or `runInInjectionContext`. Called in `ngOnInit` it fails with `NG0203`. Three ways out: 1. **Move the call** to a field initializer or the constructor. This is almost always right for a component. 2. **Pass an injector**: store `private readonly injector = inject(Injector);` and call `toSignal(source$, { injector: this.injector })`. `toSignal` then takes the `DestroyRef` from that injector, so cleanup still follows the component. 3. **Opt out of automatic cleanup**: `{ manualCleanup: true }` uses no `DestroyRef` and needs no injection context, but the subscription lasts until the Observable **completes**. That is fine for an HTTP call and a leak for a WebSocket feed. ## Rule 2: not in a reactive context A **reactive context** is code that Angular re-runs when the signals it read change: a `computed()` derivation, an `effect()` callback, or a component's template. If `toSignal()` ran there, every re-evaluation would create a **new subscription** and a new signal, and the old ones would never be cleaned up. `toSignal()` therefore asserts that no reactive consumer is active and, in development mode, throws `NG0602` with a message that invoking `toSignal` causes new subscriptions every time and that it should be moved outside the reactive context. A common way to hit it without noticing is a **getter used in the template**: ```ts get quote() { return toSignal(this.feed.watch(this.symbol()))(); } // throws NG0602 in dev ``` The assertion is **development-only**. A production build does not check it; the getter above would then fail on the injection context instead, but a variant that passes an `injector` or `manualCleanup` option would subscribe on every change-detection pass without any error. That is why the error must be fixed, not suppressed. ## The pattern that satisfies both rules When the stream depends on changing state (here, a `symbol` signal input), derive the Observable once and convert it once, in a field initializer: 1. `toObservable(this.symbol)` turns the input into a stream. 2. `switchMap(s => this.feed.watch(s))` moves to the right feed for each symbol. 3. `toSignal(...)` converts the result once, in the injection context, outside any reactive context. `rxResource` is another option for input-driven loading and is covered with resources. ## What interviewers listen for - **`NG0203`** comes from the missing **injection context**; fixes are a field initializer, the **`injector`** option or **`manualCleanup`** for completing sources. - **`NG0602`** comes from a **reactive context**; the danger is a subscription per evaluation. - The reactive-context check is **development-only**. - The cure is **derive once, convert once**.

  • Is `{ manualCleanup: true }` a safe fix for NG0203 with a WebSocket feed?
    No. Manual cleanup means no `DestroyRef` is used and the subscription lasts until the Observable completes. A price feed does not complete, so the subscription outlives the component. Pass `{ injector }` instead, so cleanup still follows the component's lifetime.
  • Why does a production build not throw NG0602?
    The reactive-context assertion runs only when `ngDevMode` is on. In production a call that also passes `injector` or `manualCleanup` (so no injection-context error occurs) would silently subscribe on every evaluation. The development error is the only warning you get.

saying these in an interview costs you the question

  • toSignal can be called in ngOnInit because the component already exists
  • manualCleanup keeps automatic cleanup but skips the injection context check
  • Calling toSignal inside computed() caches one subscription per computed
  • NG0602 also stops the problem in production builds
  • Wrapping toSignal in untracked() inside a template getter is the recommended fix