From 56d264f8476857a0cda407f38168bbc09a30bd0c Mon Sep 17 00:00:00 2001 From: aminesbdev Date: Sun, 9 Aug 2026 13:48:37 +0200 Subject: [PATCH] docs: add RxJS and Signals interoperability reference to developer skill --- skills/dev-skills/angular-developer/SKILL.md | 1 + .../references/rxjs-interop.md | 157 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 skills/dev-skills/angular-developer/references/rxjs-interop.md diff --git a/skills/dev-skills/angular-developer/SKILL.md b/skills/dev-skills/angular-developer/SKILL.md index 6ab0e3d77009..286df22077b4 100644 --- a/skills/dev-skills/angular-developer/SKILL.md +++ b/skills/dev-skills/angular-developer/SKILL.md @@ -60,6 +60,7 @@ When managing state and data reactivity, use Angular Signals and consult the fol - **Dependent State (`linkedSignal`)**: Creating writable state linked to source signals. Read [linked-signal.md](references/linked-signal.md) - **Async Reactivity (`resource`)**: Fetching asynchronous data directly into signal state. Read [resource.md](references/resource.md) - **Side Effects (`effect`)**: Logging, third-party DOM manipulation (`afterRenderEffect`), and when NOT to use effects. Read [effects.md](references/effects.md) +- **RxJS and Signals Interoperability**: Bridging the gap between Observables and Signals using `toSignal`, `toObservable`, and `takeUntilDestroyed`. Read [rxjs-interop.md](references/rxjs-interop.md) ## HTTP Communication diff --git a/skills/dev-skills/angular-developer/references/rxjs-interop.md b/skills/dev-skills/angular-developer/references/rxjs-interop.md new file mode 100644 index 000000000000..d7d403e3e7f0 --- /dev/null +++ b/skills/dev-skills/angular-developer/references/rxjs-interop.md @@ -0,0 +1,157 @@ +# RxJS and Signals Interoperability + +Angular provides a unified reactive model by bridging **RxJS** (ideal for asynchronous event streams) and **Signals** (ideal for synchronous application state and template rendering). + +Use the `@angular/core/rxjs-interop` package to convert between the two models and manage subscriptions cleanly. + +--- + +## Converting Observables to Signals (`toSignal`) + +Use `toSignal` to read values from an Observable as a reactive Signal. This allows you to bind asynchronous streams directly to templates without using the `async` pipe. + +```ts +import {Component, inject} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {UserService} from './user.service'; + +@Component({ + selector: 'app-user-profile', + template: ` + @if (user()) { +

Welcome, {{ user()?.name }}

+ } + `, +}) +export class UserProfile { + private readonly userService = inject(UserService); + + // Convert the Observable stream to a read-only Signal + readonly user = toSignal(this.userService.getCurrentUser()); +} +``` + +- **Subscription Management**: `toSignal` automatically subscribes to the Observable immediately and unsubscribes when the containing component or service is destroyed. +- **Initial Value**: By default, the resulting signal returns `undefined` before the Observable emits its first value. Use the `initialValue` option to set a default: + ```ts + readonly user = toSignal(this.userService.getCurrentUser(), {initialValue: {name: 'Guest'}}); + ``` +- **Synchronous Observables**: If the Observable emits synchronously upon subscription (e.g. a `BehaviorSubject`), pass `{ requireSync: true }` to avoid an `undefined` initial type: + ```ts + readonly theme = toSignal(this.themeService.theme$, {requireSync: true}); + ``` +- **Error Handling**: If the Observable emits an error, reading the signal will throw that error. You can catch the error using standard try/catch or an error boundary. + +--- + +## Converting Signals to Observables (`toObservable`) + +Use `toObservable` to track changes to a Signal and pipe them into RxJS operators. This is highly useful for reacting to state changes and triggering asynchronous operations (like search auto-complete). + +```ts +import {Component, inject, signal} from '@angular/core'; +import {toObservable, toSignal} from '@angular/core/rxjs-interop'; +import {debounceTime, distinctUntilChanged, switchMap} from 'rxjs/operators'; +import {SearchService} from './search.service'; + +@Component({ + selector: 'app-search', + template: ` + + + `, +}) +export class SearchComponent { + private readonly searchService = inject(SearchService); + + readonly query = signal(''); + + // 1. Convert signal query to an Observable + // 2. Debounce and switchMap to fetch data + // 3. Convert back to a Signal for template binding + readonly results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((q) => this.searchService.search(q)) + ), + {initialValue: []} + ); +} +``` + +- **Execution Timing**: `toObservable` uses an `effect` internally to monitor changes. This means emissions are asynchronous and coalesced into microtask timings. +- **Injection Context**: `toObservable` must run in an injection context (such as a constructor or field initializer) unless you explicitly pass an `Injector`. + +--- + +## Automatic Unsubscription (`takeUntilDestroyed`) + +Use `takeUntilDestroyed` to automatically complete an Observable stream and clean up subscriptions when the active component, directive, or service is destroyed. + +### Inside an Injection Context + +When called inside a constructor or property initializer, `takeUntilDestroyed` automatically resolves the current `DestroyRef`. + +```ts +import {Component, inject} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {NavigationEnd, Router} from '@angular/router'; +import {filter} from 'rxjs/operators'; + +@Component({ + selector: 'app-analytics', + template: `...`, +}) +export class AnalyticsComponent { + private readonly router = inject(Router); + + constructor() { + // Subscription will automatically be cleaned up on component destroy + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed() + ).subscribe((event) => { + this.trackPageView(event.url); + }); + } + + private trackPageView(url: string) { /* ... */ } +} +``` + +### Outside an Injection Context + +If you use `takeUntilDestroyed` inside helper methods or lifecycle hooks, you must explicitly inject and pass `DestroyRef`. + +```ts +import {Component, DestroyRef, inject, OnInit} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {interval} from 'rxjs'; + +@Component({ + selector: 'app-timer', + template: `...`, +}) +export class TimerComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + + ngOnInit() { + interval(1000).pipe( + takeUntilDestroyed(this.destroyRef) // Explicitly pass the destroy reference + ).subscribe((val) => console.log(val)); + } +} +``` + +--- + +## Recommended Patterns + +- **State vs Streams**: Use **Signals** for synchronous UI state, data bindings, and derived values. Use **RxJS** for asynchronous events, timers, HTTP calls, WebSockets, or multi-step coordination pipelines. +- **Async in Templates**: Prefer converting Observables to Signals with `toSignal` instead of subscribing manually or using the `async` pipe. +- **Clean Pipelines**: Do not write to signals inside an RxJS `tap` operator if you can avoid it; instead, use `toSignal` to derive the state.