| |

Angular 51 🅰️ Signal Interop with RxJS

Signals and RxJS are the two reactive models in modern Angular, and they are not competing. Signals are for synchronous state — a counter, a filter, a form value — and they are read with () and tracked automatically. RxJS is for asynchronous streams — HTTP, WebSockets, timers, route parameters — and the operators compose them. The two are the two halves of the reactive model, and the @angular/core/rxjs-interop package provides the bridges. The toSignal function converts an Observable to a signal, and the toObservable function converts a signal to an Observable. The result is a hybrid application where the state lives in signals and the streams flow through RxJS, and the conversions connect the two. This chapter covers the interop in detail: the two functions, the initialValue and requireSync options, the timing rules, the injection context requirement, the subscription management, and the patterns that make the hybrid code predictable.

Key point: toSignal(obs$) subscribes to the Observable and returns a signal whose value is the latest emission. It requires an injection context, and it cleans up the subscription when the context is destroyed. The initialValue option provides the value before the first emission, and the requireSync option asserts that the source emits synchronously. toObservable(sig) returns an Observable that emits the signal’s value on each change, and it also requires an injection context. The two functions are the only sanctioned bridges: signals and Observables are not interchangeable, and mixing them without the bridges produces the class of bugs the two models were designed to prevent. The takeUntilDestroyed operator is the cleanup for the subscriptions that stay in the RxJS world.


Why the two models coexist

The Angular team’s guidance is signals for the state and RxJS for the streams. The two are not competing, and the guidance is not a migration plan. The state — the form value, the selected filter, the current user — is a signal. The stream — the HTTP request, the WebSocket message, the route parameter — is an Observable.

The signal’s strength. A signal is read synchronously with (), it tracks its dependencies automatically, and the change detection is signal-driven. A signal is the right model for the value that has a current state and that the template reads.

The RxJS strength. An Observable is a stream that emits over time. The operators — debounceTime, switchMap, catchError, retry, combineLatest — compose the stream. The RxJS is the right model for the asynchronous flow.

Why the two are not interchangeable. A signal has no debounceTime, no switchMap, no retry. An Observable has no synchronous read, no automatic dependency tracking, no signal-driven change detection. The two are the different tools, and the two are the pair.

Why the hybrid is the modern pattern. A component has both kinds of state. The filter is a signal; the search results are a stream. The view model is derived from both, and the bridges connect them. The hybrid is the practical answer, and the two functions are the connection.

Why the bridges are the only sanctioned connection. The toSignal and the toObservable are the official functions. The manual subscribe in a constructor and the manual BehaviorSubject for the signal are the older patterns, and the two functions replace them. The bridges manage the subscription and the cleanup, and the manual patterns do not.

Why the conversions should be deliberate. A conversion adds a layer, and the layer should earn its place. A signal that is read in the template does not need to be converted. A signal that drives an asynchronous pipeline is the case for the conversion. The conversions should be the deliberate choice, not the default.

Why the conversions are cheap. The toSignal subscribes to the Observable and produces a signal. The toObservable produces an Observable that emits on the signal’s change. The two are the lightweight views, and the cost is the subscription, not the duplication of the state.

Why the RxJS operators remain essential. The signals handle the state, but the streams still need the operators. The search needs the debounceTime, the request needs the switchMap, the error needs the catchError. The RxJS is the operators, and the operators are the composition. The signals do not replace the RxJS; they complement it.


The toSignal function

The toSignal function converts an Observable to a signal. It subscribes to the Observable and returns a signal whose value is the latest emission.

import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';

@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    @if (users(); as list) {
      @for (user of list; track user.id) {
        <div>{{ user.name }}</div>
      }
    }
  `,
})
export class UsersComponent {
  private readonly http = inject(HttpClient);

  readonly users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] },
  );
}

The users signal is initialized with the empty array and updated when the HTTP request completes. The template reads users() synchronously, and the async pipe is not needed.

Why the initialValue is required for an async source. A signal must have a value at all times, and an Observable may not have emitted yet. The initialValue is the value before the first emission. Without it, the signal’s type is T | undefined, and the template must handle the undefined.

Why the requireSync is for the synchronous source. The requireSync: true option asserts that the source emits synchronously on subscription. The signal’s type is Signal<T>, and the undefined is not in the type. The option is for the BehaviorSubject and the of(...) sources, and the wrong use is the runtime error.

readonly count = toSignal(this.count$, { requireSync: true });
// count is Signal<number>, not Signal<number | undefined>

Why the initialValue and the requireSync are mutually exclusive. The two options are the two ways to handle the initial value, and the one implies the other’s absence. The initialValue provides the value, and the requireSync asserts the synchronous emission. The two together is the error.

Why the subscription is managed. The toSignal subscribes when the injection context is created, and it unsubscribes when the context is destroyed. The subscription’s lifetime is the component’s lifetime, and the manual unsubscribe is not needed.

Why the toSignal must be called in an injection context. The function uses the inject() internally to get the DestroyRef, and it must be called during the component’s construction or in a field initializer. The call in a method or after the constructor is the error.

Why the toSignal can lose the values. The conversion subscribes to the Observable, and the values before the subscription are missed. A cold Observable that emits on the subscription — the HTTP request, the interval — is fine. A hot Observable that emits before the subscription is the problem, and the BehaviorSubject or the ReplaySubject is the fix.

Why the toSignal is the replacement for the async pipe. The async pipe subscribes in the template, renders the value, and unsubscribes on the destroy. The toSignal does the same, but the value is a signal that can be read synchronously and combined with the computed. The signal is more flexible, and it integrates with the rest of the signal model.


The toObservable function

The toObservable function converts a signal to an Observable. It returns an Observable that emits the signal’s value on each change.

import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';

@Component({ selector: 'app-search', standalone: true, template: `` })
export class SearchComponent {
  private readonly http = inject(HttpClient);
  readonly term = signal('');

  readonly results$ = toObservable(this.term).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
  );
}

The term signal is the input, and the toObservable converts it to a stream. The operators — debounceTime, distinctUntilChanged, switchMap — are applied to the stream, and the result is an Observable.

Why the signal is the source of truth. The term signal can be read synchronously, set by a template binding, and combined with a computed. The Observable is the derived pipeline, and the signal is the state. The conversion is the bridge.

Why the Observable is converted back. The results$ is an Observable, and if the template reads it, the async pipe is needed. The toSignal converts it back, and the template reads a signal.

readonly results = toSignal(this.results$, { initialValue: [] });

The pipeline is: signal → Observable (with operators) → signal. The intermediate Observable is the pipeline, and the final signal is the template’s source.

Why the conversion is cheap. The toObservable produces an Observable that emits on the signal’s change. The emission is synchronous with the signal’s update, and the subscription is managed by the injection context. The conversion does not duplicate the state; it is a view.

Why the conversion has a subtle timing. The toObservable emits the current value on subscription, then the subsequent changes. The first emission is the signal’s value at the subscription time. The operators that expect a delay — debounceTime — delay the first emission too, which may or may not be desired.

Why the startWith is sometimes needed. The toObservable with a debounceTime delays the first emission. The startWith provides the synchronous first value, and the pipeline emits promptly.

toObservable(this.term).pipe(
  startWith(''),  // the initial value
  debounceTime(300),
  ...
)

Why the toObservable must be called in an injection context. The function uses the inject() internally to get the DestroyRef, and it must be called in the component’s construction. The call in a method is the error.

Why the toObservable should be used sparingly. A signal that is only read in the template does not need to be converted. A signal that feeds the asynchronous pipeline is the case for the conversion. The conversion adds a layer, and the layer should earn its place.


The timing and the change detection

The timing of the two functions is the detail that makes the hybrid predictable. The toSignal and the toObservable have the specific behaviors.

The toSignal‘s subscription. The toSignal subscribes to the Observable when the injection context is created. The subscription is synchronous with the function call, and the first emission is the Observable’s first value.

The toSignal‘s update. The signal’s value is updated when the Observable emits. The update is synchronous with the emission, and the change detection is triggered by the signal.

The toObservable‘s emission. The Observable emits the signal’s value on the subscription and on each change. The emission is synchronous with the signal’s change, and the operators apply.

The toObservable‘s subscription. The returned Observable is cold — it subscribes to the signal’s changes when it is subscribed. The subscription is the consumer’s, and the toObservable does not subscribe on its own.

Why the timing matters for the operators. The debounceTime and the throttleTime depend on the timing of the emissions. The toObservable‘s synchronous emission means the operators see the values as they change, and the delay is the operator’s.

Why the timing matters for the change detection. The toSignal‘s update triggers the change detection. The toObservable‘s emission does not trigger the change detection on its own — the consumer’s subscription is the trigger. The two are the different, and the difference is the model.

Why the effect and the subscription are the different tools. The effect reacts to the signal’s change and runs after the change detection. The subscription reacts to the Observable’s emission and runs synchronously. The two are the different timing, and the choice depends on the side effect.

Why the two functions are the only bridges. The signals and the Observables are the two models, and the two functions are the sanctioned connection. The manual BehaviorSubject and the manual subscribe are the older patterns, and the two functions replace them. The bridges manage the subscription, and the manual patterns do not.


The subscription cleanup

The toSignal manages the subscription, and the toObservable returns a cold Observable. The subscriptions that stay in the RxJS world need the takeUntilDestroyed.

The toSignal‘s cleanup. The toSignal subscribes to the Observable and unsubscribes when the injection context is destroyed. The cleanup is automatic, and the manual unsubscribe is not needed.

The toObservable‘s cleanup. The toObservable returns a cold Observable. The consumer’s subscription is the cleanup’s responsibility, and the takeUntilDestroyed is the modern operator.

toObservable(this.term).pipe(
  switchMap((term) => this.http.get(`/api/search?q=${term}`)),
  takeUntilDestroyed(this.destroyRef),
).subscribe((results) => console.log(results));

The takeUntilDestroyed completes the Observable when the component is destroyed, and the subscription is released. The operator is the cleanup for the subscriptions that stay in the RxJS world.

Why the takeUntilDestroyed is the modern pattern. The operator is the Angular 16+ replacement for the Subject + the takeUntil pattern. The takeUntilDestroyed uses the DestroyRef, and the cleanup is automatic.

Why the takeUntilDestroyed must be in an injection context. The operator uses the inject() internally when the destroyRef is not passed. The operator in the injection context uses the current DestroyRef, and the operator outside needs the explicit destroyRef.

Why the toSignal‘s cleanup is simpler. The toSignal is the one function call, and the cleanup is the automatic. The RxJS subscription needs the takeUntilDestroyed, and the two are the different levels of the convenience.

Why the async pipe is the other cleanup. The async pipe subscribes in the template and unsubscribes on the destroy. The pipe is the template’s cleanup, and the takeUntilDestroyed is the class’s cleanup. The two are the different contexts.

Why the manual unsubscribe is the legacy. The manual unsubscribe in the ngOnDestroy is the older pattern. The takeUntilDestroyed and the toSignal are the modern, and the manual is the legacy.

Why the leak is the risk. A subscription that is not cleaned up continues to run after the component is destroyed. The callbacks hold the references, and the memory is not released. The takeUntilDestroyed and the toSignal are the prevention.


Common pitfalls

The interop has the pitfalls, and each is the model’s misunderstanding.

The toSignal without the initialValue. The signal’s type is T | undefined, and the template must handle the undefined. The fix is the initialValue or the requireSync.

The requireSync on a cold source. The requireSync: true asserts the synchronous emission, and the cold source — the HTTP request, the interval — does not emit synchronously. The result is the runtime error, and the initialValue is the fix.

The toObservable with the debounceTime. The debounceTime delays the first emission, and the pipeline does not emit promptly. The startWith provides the synchronous first value.

The conversion of a signal that does not need it. The signal that is read only in the template does not need the conversion. The conversion adds the layer, and the layer is the unnecessary.

The subscription without the cleanup. The toObservable returns the cold Observable, and the subscription needs the takeUntilDestroyed. The missing cleanup is the leak.

The toSignal outside the injection context. The function needs the injection context, and the call in the method is the error. The fix is the constructor or the field initializer.

The mixing of the models without the bridges. The manual BehaviorSubject and the manual subscribe are the older patterns. The toSignal and the toObservable are the modern, and the manual is the legacy.

The toObservable in the effect. The effect is for the side effects, and the toObservable in the effect is the redundant. The effect reads the signal directly, and the toObservable is the unnecessary.

Why the pitfalls are about the model. Each pitfall is the misunderstanding of the timing, the injection context, or the cleanup. The two models are the different, and the bridges are the connection. The understanding of the two is the skill.

Why the interop is the modern Angular’s core. The signals and the RxJS are the two halves of the reactive model. The toSignal and the toObservable are the bridges, and the two functions are the connection. The hybrid application is the modern Angular, and the interop is the core of the hybrid.


Complete Example Session

import { Component, DestroyRef, inject, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { BehaviorSubject, of, combineLatest } from 'rxjs';
import {
  debounceTime, distinctUntilChanged, switchMap, catchError,
  startWith, map, takeUntilDestroyed,
} from 'rxjs/operators';

// ============================================
// PART 1: THE TO SIGNAL
// ============================================

@Component({ selector: 'app-users', standalone: true, template: `{{ users() }}` })
export class UsersComponent {
  private readonly http = inject(HttpClient);

  readonly users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] },
  );
}

// ============================================
// PART 2: THE REQUIRE SYNC
// ============================================

@Component({ selector: 'app-count', standalone: true, template: `{{ count() }}` })
export class CountComponent {
  private readonly count$ = new BehaviorSubject(0);

  readonly count = toSignal(this.count$, { requireSync: true });
  // count is Signal<number>, not Signal<number | undefined>
}

// ============================================
// PART 3: THE TO OBSERVABLE
// ============================================

@Component({ selector: 'app-search', standalone: true, template: `` })
export class SearchComponent {
  private readonly http = inject(HttpClient);
  readonly term = signal('');

  readonly results$ = toObservable(this.term).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
  );
}

// ============================================
// PART 4: THE ROUND TRIP
// ============================================

@Component({ selector: 'app-round', standalone: true, template: `{{ results() }}` })
export class RoundTripComponent {
  private readonly http = inject(HttpClient);
  readonly term = signal('');

  readonly results = toSignal(
    toObservable(this.term).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
      catchError(() => of([])),
    ),
    { initialValue: [] },
  );
}

// ============================================
// PART 5: THE START WITH
// ============================================

@Component({ selector: 'app-prompt', standalone: true, template: `` })
export class PromptComponent {
  readonly term = signal('');

  readonly results$ = toObservable(this.term).pipe(
    startWith(''),
    debounceTime(300),
    switchMap((term) => this.http.get(`/api/search?q=${term}`)),
  );
}

// ============================================
// PART 6: THE COMBINATION
// ============================================

@Component({ selector: 'app-mixed', standalone: true, template: `` })
export class MixedComponent {
  private readonly http = inject(HttpClient);
  private readonly user = signal<User | null>(null);
  private readonly filter = signal('');

  readonly data = toSignal(
    combineLatest([
      toObservable(this.user),
      toObservable(this.filter).pipe(debounceTime(300), distinctUntilChanged()),
    ]).pipe(
      switchMap(([user, filter]) =>
        this.http.get<Item[]>(`/api/items?q=${filter}`).pipe(
          map((items) => ({ user, items, loading: false })),
          catchError(() => of({ user, items: [], loading: false })),
          startWith({ user, items: [], loading: true }),
        ),
      ),
    ),
    { initialValue: { user: null, items: [], loading: false } },
  );
}

// ============================================
// PART 7: THE TAKE UNTIL DESTROYED
// ============================================

@Component({ selector: 'app-subscribe', standalone: true, template: `` })
export class SubscribeComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  readonly term = signal('');

  constructor() {
    toObservable(this.term).pipe(
      switchMap((term) => this.http.get(`/api/search?q=${term}`)),
      takeUntilDestroyed(this.destroyRef),
    ).subscribe((results) => console.log(results));
  }
}

// ============================================
// PART 8: THE COMPUTED ON THE SIGNAL
// ============================================

@Component({ selector: 'app-computed', standalone: true, template: `` })
export class ComputedComponent {
  private readonly http = inject(HttpClient);

  readonly users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] },
  );

  readonly count = computed(() => this.users().length);
  readonly active = computed(() => this.users().filter((u) => u.active));
}

// ============================================
// PART 9: THE EFFECT WITH THE SIGNAL
// ============================================

@Component({ selector: 'app-effect', standalone: true, template: `` })
export class EffectComponent {
  readonly term = signal('');

  constructor() {
    effect(() => {
      console.log('term changed:', this.term());
    });
  }
}

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't forget the initialValue
// toSignal(this.http.get('/api'))  // Signal<T | undefined>

// Don't use the requireSync on a cold source
// toSignal(this.http.get('/api'), { requireSync: true })  // error

// Don't forget the takeUntilDestroyed
// toObservable(this.term).pipe(switchMap(...)).subscribe(...)  // leak

// Don't convert a signal that does not need it
// toObservable(this.staticValue)  // unnecessary

// Don't use the toObservable in the effect
// effect(() => { toObservable(this.term).subscribe(...) });  // redundant

// Don't mix the models without the bridges
// this.term.subscribe(...)  // the signal has no subscribe

The ten parts cover the toSignal, the requireSync, the toObservable, the round trip, the startWith, the combination, the takeUntilDestroyed, the computed, the effect, and the anti-patterns.


Quick Reference

The Bridges

FunctionDirectionRequires
toSignal(obs$)RxJS → SignalInjection context
toObservable(sig)Signal → RxJSInjection context

The toSignal Options

OptionPurpose
initialValueThe value before the first emission
requireSyncAssert the synchronous emission

The Timing

FunctionSubscriptionEmission
toSignalOn the context’s creationOn the Observable’s emission
toObservableOn the consumer’s subscriptionOn the signal’s change

The Cleanup

PatternCleanup
toSignalAutomatic
toObservable + takeUntilDestroyedOn the destroy
async pipeOn the destroy
Manual subscribeManual unsubscribe

The Patterns

PatternCode
Observable to signaltoSignal(obs$, { initialValue })
Signal to ObservabletoObservable(sig)
Round triptoSignal(toObservable(sig).pipe(...), { initialValue })
CombinationcombineLatest([toObservable(a), toObservable(b)])
SubscriptiontoObservable(sig).pipe(takeUntilDestroyed(ref)).subscribe()

Best Practices

✅ Do This:

// Use the initialValue for the async source
readonly users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] }); // ✅

// Use the requireSync for the synchronous source
readonly count = toSignal(this.count$, { requireSync: true }); // ✅

// Use the toObservable for the pipeline
toObservable(this.term).pipe(debounceTime(300), switchMap(search)) // ✅

// Convert back to a signal for the template
readonly results = toSignal(this.results$, { initialValue: [] }); // ✅

// Use the startWith for the prompt first emission
toObservable(this.term).pipe(startWith(''), debounceTime(300))  // ✅

// Use the takeUntilDestroyed for the subscription
toObservable(this.term).pipe(takeUntilDestroyed(this.destroyRef)).subscribe() // ✅

// Use the computed on the signal
readonly count = computed(() => this.users().length);           // ✅

❌ Don’t Do This:

// Don't forget the initialValue
toSignal(this.http.get('/api'))  // Signal<T | undefined>        // ⚠️

// Don't use the requireSync on a cold source
toSignal(this.http.get('/api'), { requireSync: true });         // ⚠️

// Don't forget the takeUntilDestroyed
toObservable(this.term).pipe(switchMap(...)).subscribe();       // ⚠️

// Don't convert a signal that does not need it
toObservable(this.staticValue);                                 // ⚠️

// Don't use the toObservable in the effect
effect(() => { toObservable(this.term).subscribe(); });         // ⚠️

// Don't call the toSignal outside the injection context
ngOnInit() { this.users = toSignal(...); }  // ❌                // ⚠️

// Don't mix the models without the bridges
this.term.subscribe(...);  // the signal has no subscribe       // ⚠️

Common Pitfalls

PitfallProblemSolution
No initialValueThe undefined in the typeAdd the initial
requireSync on the coldThe runtime errorUse initialValue
No cleanupThe leaktakeUntilDestroyed
The debounceTime delays the firstThe no prompt emissionstartWith
The conversion of the staticThe unnecessary layerRead the signal
Outside the injection contextThe compile errorThe constructor
The mixing without the bridgesThe wrong modelUse the functions

Real-World Examples

1. The HTTP to signal

readonly users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });

2. The signal to Observable

toObservable(this.term).pipe(debounceTime(300), switchMap(search))

3. The round trip

readonly results = toSignal(
  toObservable(this.term).pipe(switchMap(search), catchError(() => of([]))),
  { initialValue: [] },
);

4. The combination

combineLatest([toObservable(this.user), toObservable(this.filter)])

5. The startWith

toObservable(this.term).pipe(startWith(''), debounceTime(300))

6. The subscription

toObservable(this.term).pipe(takeUntilDestroyed(this.destroyRef)).subscribe()

7. The computed

readonly count = computed(() => this.users().length);

8. The effect

effect(() => console.log(this.term()));

9. The requireSync

readonly count = toSignal(this.count$, { requireSync: true });

10. The initialValue

toSignal(this.http.get('/api'), { initialValue: [] })

Visual: The Two Models

┌──────────────────────────────────────────────────────────┐
│  SIGNALS                                                 │
│    Synchronous. Read with ().                            │
│    Track the dependencies.                               │
│    State: counters, filters, form values.                │
│    Change detection: signal-driven.                      │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  RXJS                                                    │
│    Asynchronous. Subscribe.                              │
│    The operators: debounceTime, switchMap, catchError.   │
│    Streams: HTTP, WebSocket, intervals.                  │
│    Change detection: manual.                             │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  THE BRIDGES                                             │
│    toSignal(obs$)      → RxJS to Signal                  │
│    toObservable(sig)   → Signal to RxJS                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Round Trip

┌──────────────────────────────────────────────────────────┐
│  signal: term                                            │
│       │                                                  │
│       │  toObservable                                    │
│       ▼                                                  │
│  Observable                                              │
│       │                                                  │
│       │  debounceTime, distinctUntilChanged, switchMap   │
│       ▼                                                  │
│  Observable of the results                               │
│       │                                                  │
│       │  toSignal                                        │
│       ▼                                                  │
│  signal: results                                         │
│       │                                                  │
│       │  the template reads results()                    │
│       ▼                                                  │
│  the rendered list                                       │
│                                                          │
│  The signal is the state, the Observable is the pipeline,│
│  and the final signal is the template's source.          │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The toSignal Subscription

┌──────────────────────────────────────────────────────────┐
│  readonly users = toSignal(this.http.get('/api/users'), { initialValue: [] });│
│                                                          │
│  THE CONSTRUCTION                                        │
│    The injection context is created.                     │
│    The toSignal subscribes to the Observable.            │
│    The HTTP request is sent.                             │
│    The signal is the initialValue.                       │
│                                                          │
│  THE RESPONSE                                            │
│    The Observable emits the users.                       │
│    The signal's value is updated.                        │
│    The template re-renders.                              │
│                                                          │
│  THE DESTROY                                             │
│    The context is destroyed.                             │
│    The subscription is unsubscribed.                     │
│                                                          │
│  The cleanup is automatic.                               │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The takeUntilDestroyed

┌──────────────────────────────────────────────────────────┐
│  toObservable(this.term).pipe(                           │
│    switchMap((term) => this.http.get(`/api/search?q=${term}`)),│
│    takeUntilDestroyed(this.destroyRef),                  │
│  ).subscribe((results) => console.log(results));         │
│                                                          │
│  THE SUBSCRIPTION                                        │
│    The Observable is subscribed.                         │
│    The signal's change triggers the pipeline.            │
│    The results are logged.                               │
│                                                          │
│  THE DESTROY                                             │
│    The DestroyRef emits.                                 │
│    The takeUntilDestroyed completes the Observable.      │
│    The subscription is released.                         │
│                                                          │
│  The cleanup is the operator's.                          │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Conversion Decision

┌──────────────────────────────────────────────────────────┐
│  Is the value read in the template?                      │
│       │                                                  │
│       ├── Yes, and the value is the state                │
│       │      └── Keep it a signal                        │
│       │                                                  │
│       ├── Yes, and the value comes from the async source │
│       │      └── toSignal(obs$)                          │
│       │                                                  │
│       └── No, the value drives the pipeline              │
│              └── toObservable(sig)                       │
│                                                          │
│  The conversions should be deliberate.                   │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Combination

┌──────────────────────────────────────────────────────────┐
│  signal: user                                            │
│  signal: filter                                          │
│       │                                                  │
│       │  toObservable                                    │
│       ▼                                                  │
│  stream: user$                                           │
│  stream: filter$.pipe(debounceTime, distinctUntilChanged)│
│       │                                                  │
│       ▼                                                  │
│  combineLatest([user$, filter$])                         │
│       │                                                  │
│       ▼                                                  │
│  switchMap(([user, filter]) =>                           │
│    http.get(...).pipe(                                   │
│      map(shape),                                         │
│      catchError(recover),                                │
│      startWith(loading),                                 │
│    ),                                                    │
│  )                                                       │
│       │                                                  │
│       ▼                                                  │
│  toSignal(vm$, { initialValue })                         │
│       │                                                  │
│       ▼                                                  │
│  signal: vm                                              │
│       │                                                  │
│       ▼                                                  │
│  the template reads vm()                                 │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

ItemValue
RxJS → SignaltoSignal(obs$)
Signal → RxJStoObservable(sig)
Initial valueinitialValue option
Synchronous sourcerequireSync option
Cleanup (toSignal)Automatic
Cleanup (toObservable)takeUntilDestroyed
Injection contextRequired for both
The stateThe signal
The streamThe Observable

Key takeaways:

  • The signals and the RxJS are the two halves of the reactive model — the signals are for the synchronous state, the RxJS is for the asynchronous streams, and the two are complementary
  • The toSignal converts an Observable to a signal — it subscribes on the context’s creation, cleans up on the destroy, and requires the initialValue for an async source
  • The requireSync option is for the synchronous source — it asserts that the source emits synchronously, and the wrong use is the runtime error
  • The toObservable converts a signal to an Observable — it emits on the signal’s change, and the operators apply to the stream
  • The round trip is the common pattern — signal → Observable (with the operators) → signal, and the final signal is the template’s source
  • The startWith is sometimes needed — the debounceTime delays the first emission, and the startWith provides the synchronous first value
  • The takeUntilDestroyed is the cleanup for the subscriptions — the toSignal‘s cleanup is automatic, and the RxJS subscription needs the operator
  • The two functions require the injection context — the toSignal and the toObservable use the inject() internally, and the call outside the context is the error
  • The conversion should be deliberate — the signal that is read in the template does not need the conversion, and the conversion adds the layer
  • The manual BehaviorSubject and the manual subscribe are the legacy — the toSignal and the toObservable are the modern, and the two functions replace the older patterns

Remember: The signals and the RxJS are the two models, and the toSignal and the toObservable are the bridges. The state lives in the signals, the streams flow through the RxJS, and the conversions connect the two. The toSignal manages the subscription, the toObservable returns the cold stream, and the takeUntilDestroyed cleans up the subscription. The hybrid application is the modern Angular, and the interop is the core of the hybrid. Use the signals for the state, the RxJS for the streams, and the two functions for the connection.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!