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 });
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).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.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[].