RxJS and Signals — Bridging with toSignal and toObservable

toSignal() and toObservable() are Angular’s bridge functions between the RxJS world and the Signals world. toSignal(observable) subscribes to an Observable, converts emitted values into a Signal, and automatically unsubscribes when the injection context is destroyed — making it the cleanest way to consume Observable data in templates without the async pipe. toObservable(signal) converts a Signal’s changes into an Observable stream — enabling Signals to drive RxJS pipelines.

toSignal and toObservable

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { signal, computed, inject, Component } from '@angular/core';

// ── toSignal — convert Observable to Signal ───────────────────────────────
@Injectable({ providedIn: 'root' })
export class PostsStateService {
  private api = inject(PostsApiService);

  // Loading state signal drives the HTTP request via toObservable
  private page  = signal(1);
  private query = signal('');

  // Combine signals into Observable, pipe through RxJS, convert back to Signal
  private request$ = combineLatest([
    toObservable(this.page),    // convert Signal → Observable
    toObservable(this.query),
  ]).pipe(
    debounceTime(200),           // avoid rapid consecutive requests
    switchMap(([page, query]) =>
      this.api.searchPosts(query, page).pipe(
        catchError(() => of({ items: [], total: 0, page, totalPages: 0,
                               pageSize: 10, hasNextPage: false, hasPrevPage: false }))
      )
    ),
    shareReplay(1),
  );

  // Convert back to Signals for template binding
  readonly results    = toSignal(this.request$, { initialValue: null });
  readonly posts      = computed(() => this.results()?.items ?? []);
  readonly total      = computed(() => this.results()?.total ?? 0);
  readonly isLoading  = toSignal(
    toObservable(this.query).pipe(
      switchMap(() => concat(of(true), this.request$.pipe(map(() => false))))
    ),
    { initialValue: false }
  );

  // Public API — update signals to trigger new requests
  setPage(page: number):   void { this.page.set(page);  }
  setQuery(query: string): void { this.query.set(query); this.page.set(1); }
}

// ── Component using the state service — no subscriptions, no async pipes ──
@Component({
  selector:   'app-post-search',
  standalone:  true,
  imports:    [ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" placeholder="Search...">

    @if (state.isLoading()) {
      <app-spinner />
    } @else {
      @for (post of state.posts(); track post.id) {
        <app-post-card [post]="post" />
      } @empty {
        <p>No results found.</p>
      }
    }

    <p>{{ state.total() }} total results</p>
  `,
})
export class PostSearchComponent implements OnInit {
  protected state   = inject(PostsStateService);
  searchControl     = new FormControl('');
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    this.searchControl.valueChanges.pipe(
      debounceTime(350),
      distinctUntilChanged(),
      takeUntilDestroyed(this.destroyRef),
    ).subscribe(query => this.state.setQuery(query ?? ''));
  }
}

// ── toSignal options ───────────────────────────────────────────────────────
// initialValue — value before Observable emits (avoids undefined)
const count = toSignal(count$, { initialValue: 0 });

// requireSync — throws if Observable does not emit synchronously
// (use with BehaviorSubject or of())
const current = toSignal(behaviorSubject$, { requireSync: true });

// injector — use outside injection context (pass the injector manually)
const value = toSignal(source$, { injector: this.injector });
Note: toSignal() must be called in an injection context — inside a constructor, ngOnInit, or a field initialiser of a class that is resolved via DI. It automatically injects DestroyRef to know when to unsubscribe, which is why it needs the injection context. If you need to call toSignal() outside an injection context (e.g., in a utility function), pass the injector explicitly: toSignal(source$, { injector: this.injector }) where this.injector = inject(Injector).
Tip: The toSignal() + toObservable() bridge pattern enables the best of both worlds: Signals drive the UI and application state (simple, synchronous, OnPush-compatible), while RxJS handles the complex async logic (debouncing, cancellation, combining streams). The pattern is: Signal state → toObservable() → RxJS pipeline → toSignal() → Signal for template binding. The RxJS pipeline in the middle can be as complex as needed without the complexity leaking into components or templates.
Warning: toSignal() returns a Signal typed as Signal<T | undefined> when no initialValue is provided, because the Observable has not emitted yet when the Signal is first read. Always provide an initialValue to get a Signal<T>: toSignal(posts$, { initialValue: [] as PostDto[] }). Without it, template code that reads signal()?.property needs extra null checks that initialValue: [] would make unnecessary.

When to Use Each Approach

Scenario Best Approach
Simple local component state Signal
HTTP response for template display toSignal(http.get(…))
Complex async pipeline (debounce, switchMap) RxJS → toSignal()
Shared state across many components BehaviorSubject.asObservable() OR service signals
Cross-component events (publish/subscribe) Subject (event bus)
Derived state from multiple signals computed()
Signal driving an RxJS pipeline toObservable(signal)

Common Mistakes

Mistake 1 — Calling toSignal() outside injection context (runtime error)

❌ Wrong — toSignal(obs$) inside a setTimeout or plain function; no DestroyRef available; throws.

✅ Correct — call toSignal() in constructor/field initialiser, or pass { injector } explicitly.

Mistake 2 — Not providing initialValue (Signal typed as T | undefined, unnecessary null checks)

❌ Wrong — toSignal(posts$); type is PostDto[] | undefined; templates need ?. everywhere.

✅ Correct — toSignal(posts$, { initialValue: [] as PostDto[] }); type is PostDto[].

🧠 Test Yourself

A component uses results = toSignal(searchResults$, { initialValue: [] }). Before the Observable emits, what does results() return in the template?