skip to content

In Angular, why does `toObservable(symbol)` emit only the last of three synchronous `symbol.set()` calls, and when does it emit at all?

level: middleimportance: should knowfreq 48%

answer

  1. an effect watches the signal
  2. effects run later, not per write
  3. a one-value replay buffer
  4. first value may arrive on subscribe
  5. completes with its injector

basics

~10 s

Angular's toObservable() forwards a signal's value through an effect into a ReplaySubject(1); the effect runs later and reads only the current value, so several synchronous set() calls produce one emission of the last value.

solid answer

~40 s

`toObservable()` creates an `effect()` that reads the signal and pushes its value into a `ReplaySubject(1)`, and returns that subject as an Observable. Signals do not notify synchronously per write; the effect is scheduled and runs later, reading whatever the value is by then. So `set(1); set(2); set(3)` in one task produce a single emission of `3`, and intermediate values are never observed. Because of the replay buffer, a subscriber that arrives after the effect has run gets the current value synchronously on subscribe; later changes arrive asynchronously. The call needs an injection context (or an `injector` option), and the Observable completes when that injector's `DestroyRef` fires. If the signal throws when read, the Observable errors.

code

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

@Component({
  selector: 'app-symbol-switcher',
  template: `
    @for (s of symbols; track s) { <button (click)="symbol.set(s)">{{ s }}</button> }
    <p>{{ quote()?.price ?? '...' }}</p>
  `,
})
export class SymbolSwitcher {
  readonly symbols = ['AAPL', 'MSFT', 'NVDA'];
  readonly symbol = signal('AAPL');
  private readonly feed = inject(PriceFeed);

  // One effect feeds symbol changes into the stream; switchMap moves to the latest feed.
  readonly quote = toSignal(
    toObservable(this.symbol).pipe(switchMap(s => this.feed.watch(s))),
  );
}

go deeper

for a junior

Know that toObservable turns a signal into an Observable, so RxJS operators can react to its changes, and that it is called once in an injection context.

for a middle

Explain the effect plus ReplaySubject(1) design, why synchronous writes coalesce, and when a subscriber receives the first value.

for a senior

Pick toObservable only for state, not for events, and reason about its asynchronous delivery when combining it with switchMap-based feeds.

for a principal

Decide where the signal-to-stream boundary sits in the architecture so event streams and state signals are not converted back and forth needlessly.

## How toObservable is built `toObservable(source)` from `@angular/core/rxjs-interop` (stable since v20) has a small implementation worth knowing: 1. It creates a **`ReplaySubject` with a buffer of one**. 2. It creates an **`effect()`** that reads `source()` and calls `subject.next(value)`; if reading the signal throws, it calls `subject.error(err)` instead. 3. It registers with the injector's **`DestroyRef`** to destroy the effect and **complete** the subject. 4. It returns `subject.asObservable()`. Everything about its timing follows from those four lines. ## Why only the last value arrives Signals are **not event streams**. Writing to a signal marks its consumers as dirty; it does not call them. The effect that `toObservable` created runs **later**, when Angular processes scheduled effects, and it reads the signal's value **at that moment**. ```ts const symbol$ = toObservable(this.symbol); symbol$.subscribe(s => console.log(s)); this.symbol.set('AAPL'); this.symbol.set('MSFT'); this.symbol.set('NVDA'); // only 'NVDA' is logged, once the effect runs ``` Angular's guide shows the same thing with numbers: after three synchronous `set` calls only the last value is logged. The exact scheduling of effects belongs to the effects topic; the key point here is that **one effect run sees one value**. Two more consequences: - Setting a signal to a value **equal** to its current one (by the signal's equality function) does not mark consumers dirty, so nothing is emitted. - `toObservable` is **the wrong tool to count events**. If every click or keystroke must be seen, use a `Subject` or an `output()` stream, not a signal. ## When it emits on subscribe | Situation | What a new subscriber receives | |---|---| | effect has not run yet (just created) | nothing until the effect runs | | effect has run at least once | the current value, **synchronously** on subscribe, from the replay buffer | | later signal changes | the new value, **asynchronously**, once per effect run | | owner destroyed | completion | All subscribers share **one effect and one buffer**; subscribing again does not create another effect. ## Injection context and lifetime The effect needs an injector, so `toObservable()` must run in an **injection context** or receive `{ injector }`. The injector's `DestroyRef` decides the lifetime: in a component the Observable completes when the component is destroyed, which also completes any `switchMap` pipeline built on it once its inner work finishes or is unsubscribed. ## A typical use The price-feed screen turns the selected symbol into a stream so an RxJS operator can switch feeds: 1. `toObservable(this.symbol)` gives the symbol as a stream. 2. `switchMap(s => this.feed.watch(s))` subscribes to the new symbol's feed and drops the old one. 3. Several writes to the symbol in the same task (for example a reset followed by a restore) collapse into one effect run, so the feed switches once, to the final symbol. ## What interviewers listen for - An **effect** plus a **`ReplaySubject(1)`**, not a synchronous bridge. - **Synchronous writes coalesce**; only the value current at the effect run is emitted. - The **first value can be synchronous** for late subscribers; later ones are asynchronous. - It needs an **injection context** and **completes** with its injector.

  • Does subscribing twice to the same toObservable result create two effects?
    No. The effect is created once, when `toObservable()` is called, and it feeds a single `ReplaySubject(1)`. Every subscriber shares that subject; a late one receives the buffered current value and then the same later emissions.
  • What happens to the Observable if the signal it watches throws when read?
    The effect catches the error and calls `error` on the subject, so subscribers receive an error notification and the stream is finished. A computed signal that throws is the usual way this happens.

saying these in an interview costs you the question

  • toObservable emits synchronously on every signal write
  • Each subscriber to a toObservable result gets its own effect
  • toObservable replays every value the signal has ever held
  • toObservable never completes, even after its component is destroyed
  • toObservable is a good way to count button clicks stored in a signal