5. RxJS-Interop
In this lab, we combine RxJS with Signals.
🔀 Branch: For this lab, please switch to the branch 05-rxjs-interop-starter:
git reset --hard
git checkout 05-rxjs-interop-starter
5.1 Implementing a Simple Typeahead
Let's implement a simple typeahead for searching for desserts.
-
Open the file
desserts.component.ts(src/app/desserts/desserts.component.ts) and remove theOnInithook as well as thesearchmethod:import { JsonPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, - OnInit, computed, inject, signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Dessert } from '../data/dessert'; -import { DessertFilter } from '../data/dessert-filter'; import { DessertService } from '../data/dessert.service'; import { DessertIdToRatingMap, RatingService } from '../data/rating.service'; import { DessertCardComponent } from '../dessert-card/dessert-card.component'; [...] styleUrl: './desserts.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DessertsComponent implements OnInit { +export class DessertsComponent { #dessertService = inject(DessertService); #ratingService = inject(RatingService); #toastService = inject(ToastService); [...] ratings = signal<DessertIdToRatingMap>({}); ratedDesserts = computed(() => this.toRated(this.desserts(), this.ratings())); - ngOnInit(): void { - this.search(); - } - - search(): void { - const filter: DessertFilter = { - originalName: this.originalName(), - englishName: this.englishName(), - }; - - this.loading.set(true); - - this.#dessertService.find(filter).subscribe({ - next: (desserts) => { - this.desserts.set(desserts); - this.loading.set(false); - }, - error: (error) => { - this.loading.set(false); - this.#toastService.show('Error loading desserts!'); - console.error(error); - }, - }); - } - toRated(desserts: Dessert[], ratings: DessertIdToRatingMap): Dessert[] { return desserts.map((d) => ratings[d.id] ? { ...d, rating: ratings[d.id] } : d, -
Also in
desserts.component.ts(src/app/desserts/desserts.component.ts), convert the Signals representing the search filter into an Observable. Use this Observable to implement a debounced typeahead:import { inject, signal, } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; +import { + catchError, + combineLatest, + debounceTime, + filter, + of, + switchMap, + tap, +} from 'rxjs'; import { Dessert } from '../data/dessert'; import { DessertService } from '../data/dessert.service'; import { DessertIdToRatingMap, RatingService } from '../data/rating.service'; [...] #toastService = inject(ToastService); originalName = signal(''); - englishName = signal(''); + englishName = signal('Cake'); loading = signal(false); - desserts = signal<Dessert[]>([]); ratings = signal<DessertIdToRatingMap>({}); ratedDesserts = computed(() => this.toRated(this.desserts(), this.ratings())); + originalName$ = toObservable(this.originalName); + englishName$ = toObservable(this.englishName); + + desserts$ = combineLatest({ + originalName: this.originalName$, + englishName: this.englishName$, + }).pipe( + filter((c) => c.originalName.length >= 3 || c.englishName.length >= 3), + debounceTime(300), + tap(() => this.loading.set(true)), + switchMap((c) => + this.#dessertService.find(c).pipe( + catchError((error) => { + this.#toastService.show('Error loading desserts!'); + console.error(error); + return of([]); + }), + ), + ), + tap(() => this.loading.set(false)), + ); + + desserts = toSignal(this.desserts$, { + initialValue: [], + }); + toRated(desserts: Dessert[], ratings: DessertIdToRatingMap): Dessert[] { return desserts.map((d) => ratings[d.id] ? { ...d, rating: ratings[d.id] } : d, -
Switch to the file
desserts.component.html(src/app/desserts/desserts.component.html) and remove theSearchbutton, as now, changing the filter already triggers the search:</div> </div> <div class="mt-10 mb-10"> - <button - type="submit" - (click)="search()" - class="btn btn-primary" - [disabled]="loading()" - > - Search - </button> - <button - type="button" - (click)="loadRatings()" - class="btn ml-2" - [disabled]="loading()" - > + <button type="button" (click)="loadRatings()" class="btn"> Expert Ratings </button> -
Try out your changes.
💾 Please find this lab's solution in the branch 05a-rxjs-interop (get reset --hard && git checkout 05a-rxjs-interop). You don't have to switch to it branch, if you are fine.
5.2 Experiment: Unsubscribing Automatically
In this experiment, you see that consumers unsubscribe automatically from their Signals.
🔀 Branch: For this lab, please switch to the branch 06-unsubscribe-experiment-starter:
git reset --hard
git checkout 06-unsubscribe-experiment-starter
-
Open the file
about.component.ts(src/app/about/about.component.ts) and create an Observable emitting a new value every second using theintervalfunction. Convert it to a Signal and write out its values in an effect. Also, directly subscribe to the Observable and write out its values too:-import { Component } from '@angular/core'; +import { Component, effect } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { interval } from 'rxjs'; @Component({ selector: 'app-about', [...] templateUrl: './about.component.html', styleUrl: './about.component.css', }) -export class AboutComponent {} +export class AboutComponent { + counter$ = interval(1000); + counter = toSignal(this.counter$); + + constructor() { + this.counter$.subscribe((c) => { + console.log('counter#x27;, c); + }); + + effect(() => { + console.log('counter', this.counter()); + }); + } +} -
To try our your changes, enter the
Aboutroute. You should see your counters on the console. -
When leaving the route, the effect stops, while the Observable's subscription proceeds with writing values to the console.
-
Apply the
takeUntilDestroyedoperator provided by@angular/core/rxjs-interopto your Observable:import { Component, effect } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { interval } from 'rxjs'; @Component({ [...] counter = toSignal(this.counter$); constructor() { - this.counter$.subscribe((c) => { + this.counter$.pipe(takeUntilDestroyed()).subscribe((c) => { console.log('counter#x27;, c); }); -
Try out your changes. Now, both counters should stop when leaving the route.
💾 Please find this lab's solution in the branch 06b-unsubscribe-experiment (get reset --hard && git checkout 06b-unsubscribe-experiment). You don't have to switch to it branch, if you are fine.
> Follow us on X (Twitter) > More Trainings (German) > More Trainings (English)