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.

  1. Open the file desserts.component.ts (src/app/desserts/desserts.component.ts) and remove the OnInit hook as well as the search method:

     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,
    
  2. 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,
    
  3. Switch to the file desserts.component.html (src/app/desserts/desserts.component.html) and remove the Search button, 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>
    
  4. 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
  1. Open the file about.component.ts (src/app/about/about.component.ts) and create an Observable emitting a new value every second using the interval function. 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());
    +    });
    +  }
    +}
    
  2. To try our your changes, enter the About route. You should see your counters on the console.

  3. When leaving the route, the effect stops, while the Observable's subscription proceeds with writing values to the console.

  4. Apply the takeUntilDestroyed operator provided by @angular/core/rxjs-interop to 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);
         });
    
  5. 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)