Signals don’t replace RxJS — they complement it. Signals are excellent for state (what is shown now), RxJS for events over time (what happens and when). The package @angular/core/rxjs-interop has the bridges between the two worlds. New to Observables? Start with Observables from zero.
| Signals | RxJS / Observable |
|---|---|
| Always have a current value | Emit values over time (they may not have emitted yet) |
Synchronous: count() | Asynchronous: subscribe() |
| No unsubscribing needed | Need unsubscribing (or an operator that completes) |
Derivation: computed() | Derivation: map, combineLatest, switchMap… |
| Ideal for templates and UI state | Ideal for websockets, drag, retry, complex debounce |
toSignal(): Observable → Signal
import { toSignal } from '@angular/core/rxjs-interop';
export class Clock {
// Automatic subscription + automatic unsubscription when the component is destroyed
readonly seconds = toSignal(interval(1000), { initialValue: 0 });
// Without initialValue → the type is T | undefined
readonly user = toSignal(this.auth.user$); // Signal<User | undefined>
// requireSync: the observable emits synchronously (BehaviorSubject, startWith) → no undefined
readonly theme = toSignal(this.theme$, { requireSync: true }); // Signal<Theme>
// Route params as a signal (if you don’t use withComponentInputBinding)
private readonly route = inject(ActivatedRoute);
readonly id = toSignal(this.route.paramMap.pipe(map((p) => Number(p.get('id')))), {
initialValue: 0,
});
}toObservable(): Signal → Observable
import { toObservable } from '@angular/core/rxjs-interop';
export class ProductSearch {
readonly query = signal('');
// signal → observable → RxJS operators → back to a signal
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
filter((q) => q.length >= 2),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
}
// toObservable emits ASYNCHRONOUSLY (through an effect) and only the final value
// if the signal is set several times in the same tick.press the buttonWhere RxJS stays better
A mousemove stream with throttle and accumulation (scan) is trivial in RxJS and awkward with signals. You put the final result into a signal for the template.
0, 0 · events processed (throttle 40 ms): 0takeUntilDestroyed(): goodbye Subject destroy$
Every manual subscribe() in a component must be stopped on destruction. takeUntilDestroyed() does this automatically using DestroyRef. (With toSignal you don’t need it — it unsubscribes by itself.)
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class Notifications {
private readonly destroyRef = inject(DestroyRef);
constructor() {
// ✓ in an injection context: no argument
this.socket.messages$.pipe(takeUntilDestroyed()).subscribe((m) => this.show(m));
}
connectLater() {
// ✓ outside the constructor: pass the DestroyRef explicitly
this.poll$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
}
}
// Before: Subject + ngOnDestroy
// private destroy$ = new Subject<void>();
// ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
// obs$.pipe(takeUntil(this.destroy$))- Create a child, then destroy it and watch the log.
Outputs from and to Observables
import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop';
export class Search {
private readonly query$ = new Subject<string>();
// An output built from an Observable
readonly queryChange = outputFromObservable(this.query$.pipe(debounceTime(300)));
}
// The other way round: you treat a component’s output as an Observable (e.g. in a directive)
const changes$ = outputToObservable(childComponentRef.instance.queryChange);Architecture recommendation
// The architecture recommendation:
// RxJS at the edges (I/O, events), Signals for the displayed state.
@Service()
export class ChatStore {
private readonly socket = inject(ChatSocket); // exposes Observable<Message>
// a stream of events → accumulated with RxJS → exposed as a signal
readonly messages = toSignal(
this.socket.messages$.pipe(scan((all, m) => [...all, m].slice(-100), [] as Message[])),
{ initialValue: [] },
);
readonly unread = computed(() => this.messages().filter((m) => !m.read).length);
}When YES, when NO
- You have events over time: websocket, SSE, drag & drop, infinite scroll.
- You need time/concurrency operators:
switchMap,exhaustMap,retrywith backoff,bufferTime. - You combine many asynchronous sources with complex rules.
- You work with APIs that expose Observables (Router events, Reactive Forms
valueChanges).
- You hold UI state (filters, selection, toggles).
- You derive values for the template —
computedis simpler thancombineLatest. - You do GETs that depend on state —
httpResource. - You use a
BehaviorSubjectonly as an “observable variable” — asignalis exactly that.
Common pitfalls
toSignaloutside the injection context → an error. Create it as a field or pass{ injector }.toSignalon a “cold” HTTP Observable in a getter → a new request on every call. Create it once.- Errors: if the Observable errors, reading the signal throws. Catch the error with
catchErrorfirst. toObservabledoesn’t emit synchronously: don’t rely on getting the value right afterset().
Cheat sheet
toSignal(obs$, { initialValue }) // Observable → Signal (auto-unsubscribe)
toSignal(obs$, { requireSync: true }) // no undefined, if it emits synchronously
toObservable(sig) // Signal → Observable (emits asynchronously)
takeUntilDestroyed() // in an injection context
takeUntilDestroyed(destroyRef) // anywhere
outputFromObservable(obs$) // Observable → output()
outputToObservable(output) // output() → Observable
rxResource({ params, stream }) // async data over an Observable → Resource
pendingUntilEvent() // keeps the application “unstable” (SSR) until the first emission