Make RxJS and Angular Signal coexist in Pokemon Application

railsstudent

Connie Leung

Posted on June 1, 2023

Make RxJS and Angular Signal coexist in Pokemon Application

Introduction

I wrote a simple Pokemon application in Angular 15 and RxJS to display image URLs of a specific Pokemon. There are 2 methods to update current Pokemon Id in order to update image URLS. The first method is by clicking buttons to increment or decrement the id by a delta. The other method is to enter a value to a number input to overwrite the current Pokemon Id. However, the number input field continues to use debounceTime, distinctUntilChanged and filter RxJS operators to perform validation and limit the values getting emitted. Therefore, the challenge is to simplify reactive codes, and make RxJS and Angular signal coexist.

Old Pokemon Component with RxJS codes

// pokemon.component.ts

...omitted import statements...

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [AsyncPipe, NgIf, FormsModule],
  template: `
    <h1>
      Display the first 100 pokemon images
    </h1>
    <div>
      <label>Pokemon Id:
        <span>{{ btnPokemonId$ | async }}</span>
      </label>
      <div class="container" *ngIf="images$ | async as images">
        <img [src]="images.frontUrl" />
        <img [src]="images.backUrl" />
      </div>
    </div>
    <div class="container">
      <button class="btn" #btnMinusTwo>-2</button>
      <button class="btn" #btnMinusOne>-1</button>
      <button class="btn" #btnAddOne>+1</button>
      <button class="btn" #btnAddTwo>+2</button>
      <form #f="ngForm" novalidate>
        <input type="number" [(ngModel)]="searchId" [ngModelOptions]="{ updateOn: 'blur' }" 
          name="searchId" id="searchId" />
      </form>
      <pre>
        searchId: {{ searchId }}
      </pre>
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent implements OnInit {
  @ViewChild('btnMinusTwo', { static: true, read: ElementRef })
  btnMinusTwo!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnMinusOne', { static: true, read: ElementRef })
  btnMinusOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddOne', { static: true, read: ElementRef })
  btnAddOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddTwo', { static: true, read: ElementRef })
  btnAddTwo!: ElementRef<HTMLButtonElement>;

  @ViewChild('f', { static: true, read: NgForm })
  myForm: NgForm;

  btnPokemonId$!: Observable<number>;
  images$!: Observable<{ frontUrl: string, backUrl: string }>;

  searchId = 1;

  ngOnInit() {
    const btnMinusTwo$ = this.createButtonClickObservable(this.btnMinusTwo, -2);
    const btnMinusOne$ = this.createButtonClickObservable(this.btnMinusOne, -1);
    const btnAddOne$ = this.createButtonClickObservable(this.btnAddOne, 1);
    const btnAddTwo$ = this.createButtonClickObservable(this.btnAddTwo, 2);

    const inputId$ = this.myForm.form.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged((prev, curr) => prev.searchId === curr.searchId),
        filter((form) => form.searchId >= 1 && form.searchId <= 100),
        map((form) => form.searchId),
        map((value) => ({
          value,
          action: POKEMON_ACTION.OVERWRITE,
        }))
      );

    this.btnPokemonId$ = merge(btnMinusTwo$, btnMinusOne$, btnAddOne$, btnAddTwo$, inputId$)
      .pipe(
        scan((acc, { value, action }) => { 
          if (action === POKEMON_ACTION.OVERWRITE) {
            return value;
          } else if (action === POKEMON_ACTION.ADD) {
            const potentialValue = acc + value;
            if (potentialValue >= 1 && potentialValue <= 100) {
              return potentialValue;
            } else if (potentialValue < 1) {
              return 1;
            }

            return 100;
          }

          return acc;
        }, 1),
        startWith(1),
        shareReplay(1),
      );

      const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
      this.images$ = this.btnPokemonId$.pipe(
        map((pokemonId: number) => ({
          frontUrl: `${pokemonBaseUrl}/shiny/${pokemonId}.png`,
          backUrl: `${pokemonBaseUrl}/back/shiny/${pokemonId}.png`
        }))
      );
  }

  createButtonClickObservable(ref: ElementRef<HTMLButtonElement>, value: number) {
    return fromEvent(ref.nativeElement, 'click').pipe(
      map(() => ({ value, action: POKEMON_ACTION.ADD }))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

My tasks are to retain most of the logic $inputId, delete all occurrences of ViewChild and replace the rest of the RxJS codes with Angular signals. I will refactor RxJS codes to signals and rewrite $inputId Observable last.

First, I create a signal to store current Pokemon id

// pokemon-component.ts

currentPokemonId = signal(1);
Enter fullscreen mode Exit fullscreen mode

Then, I modify inline template to add click event to the button elements to update currentPokemonId signal.

Before (RxJS)

<div class="container">
   <button class="btn" #btnMinusTwo>-2</button>
   <button class="btn" #btnMinusOne>-1</button>
   <button class="btn" #btnAddOne>+1</button>
   <button class="btn" #btnAddTwo>+2</button>
</div>
Enter fullscreen mode Exit fullscreen mode
After (Signal)

<div class="container">
   <button class="btn" (click)="updatePokemonId(-2)">-2</button>
   <button class="btn" (click)="updatePokemonId(-1)">-1</button>
   <button class="btn" (click)="updatePokemonId(1)">+1</button>
   <button class="btn" (click)="updatePokemonId(2)">+2</button>
 </div>
Enter fullscreen mode Exit fullscreen mode

In signal version, I remove template variables such that the component does not require ViewChild to query HTMLButtonElement

readonly min = 1;
readonly max = 100;

updatePokemonId(delta: number) {
    this.currentPokemonId.update((pokemonId) => {
      const newId = pokemonId + delta;
      return Math.min(Math.max(this.min, newId), this.max);
    });
}
Enter fullscreen mode Exit fullscreen mode

When button is clicked, updatePokemonId sets currentPokemonId to a value between 1 and 100.

Next, I further modify inline template to replace images$ Observable with imageUrls computed signal and btnPokemonId$ Observable with currentPokemonId

Before (RxJS)

<div>
   <label>Pokemon Id:
      <span>{{ btnPokemonId$ | async }}</span>
   </label>
   <div class="container" *ngIf="images$ | async as images">
      <img [src]="images.frontUrl" />
      <img [src]="images.backUrl" />
   </div>
</div>
Enter fullscreen mode Exit fullscreen mode
After (Signal)

<div>
   <label>Pokemon Id:
      <span>{{ currentPokemonId() }}</span>
   </label>
   <div class="container">
      <img [src]="imageUrls().front" />
      <img [src]="imageUrls().back" />
   </div>
</div>
Enter fullscreen mode Exit fullscreen mode

In signal version, I invoke currentPokemonId() to display the current Pokemon id. imageUrls is a computed signal that returns front and back URLs of pokemon.

const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';

imageUrls = computed(() => ({
    front: `${pokemonBaseUrl}/shiny/${this.currentPokemonId()}.png`,
    back: `${pokemonBaseUrl}/back/shiny/${this.currentPokemonId()}.png`
}));
Enter fullscreen mode Exit fullscreen mode

After applying these changes, the inline template does not rely on async pipe and ngIf. Therefore, I can remove NgIf and AsyncPipe from the imports array.

Make RxJS and Angular Signal coexist in Component Class

Now, I have to tackle $inputId Observable such that it can invoke RxJS operators and update currentPokemonId signal correctly. My solution is to call subscribe and update currentPokemonId in it. Calling subscribe creates a subscription unfortunately; therefore, I import takeUntilDestroyed to complete the Observable in the constructor.

Before (RxJS)

ngOnInit() {
    const inputId$ = this.myForm.form.valueChanges
       .pipe(
         debounceTime(300),
         distinctUntilChanged((prev, curr) => prev.searchId === curr.searchId),
         filter((form) => form.searchId >= 1 && form.searchId <= 100),
         map((form) => form.searchId),
         map((value) => ({
           value,
           action: POKEMON_ACTION.OVERWRITE,
         }))
       );
}
Enter fullscreen mode Exit fullscreen mode
After (Signal)

<input type="number" 
        [ngModel]="searchIdSub.getValue()"
        (ngModelChange)="searchIdSub.next($event)"
        name="searchId" id="searchId" />
Enter fullscreen mode Exit fullscreen mode

[(ngModel)] is decomposed to [ngModel] and (ngModelChange) to get my solution to work. searchIdSub is a BehaviorSubject that initializes to 1. NgModel input is bounded to searchIdSub.getValue() and (ngModelChange) updates the BehaviorSubject when input value changes.

searchIdSub = new BehaviorSubject(1);

constructor() {
    this.searchIdSub
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        filter((value) => value >= this.min && value <= this.max),
        takeUntilDestroyed(),
      ).subscribe((value) => this.currentPokemonId.set(value));
}
Enter fullscreen mode Exit fullscreen mode

In the constructor, this.searchIdSub emits values to RxJS operators and invokes subscribe to update currentPokemonId signal. Angular 16 introduces takeUntilDestroyed that completes Observable; therefore, I don’t have to implement OnDestroy interface to unsubscribe subscription manually.

New Pokemon Component using Angular Signals

// pokemon.component.ts

...omitted import statements due to brevity...

const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h2>
      Display the first 100 pokemon images
    </h2>
    <div>
      <label>Pokemon Id:
        <span>{{ currentPokemonId() }}</span>
      </label>
      <div class="container">
        <img [src]="imageUrls().front" />
        <img [src]="imageUrls().back" />
      </div>
    </div>
    <div class="container">
      <button class="btn" (click)="updatePokemonId(-2)">-2</button>
      <button class="btn" (click)="updatePokemonId(-1)">-1</button>
      <button class="btn" (click)="updatePokemonId(1)">+1</button>
      <button class="btn" (click)="updatePokemonId(2)">+2</button>
      <input type="number" 
        [ngModel]="searchIdSub.getValue()"
        (ngModelChange)="searchIdSub.next($event)"
        name="searchId" id="searchId" />
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  readonly min = 1;
  readonly max = 100;

  searchIdSub = new BehaviorSubject(1);
  currentPokemonId = signal(1);

  imageUrls = computed(() => {
    const pokemonId = this.currentPokemonId();
    return {
      front: `${pokemonBaseUrl}/shiny/${pokemonId}.png`,
      back: `${pokemonBaseUrl}/back/shiny/${pokemonId}.png` 
    }
  });

  constructor() {
    this.searchIdSub
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        filter((value) => value >= this.min && value <= this.max),
        takeUntilDestroyed(),
      ).subscribe((value) => this.currentPokemonId.set(value));
  }

  updatePokemonId(delta: number) {
    this.currentPokemonId.update((pokemonId) => 
      Math.min(Math.max(this.min, pokemonId + delta), this.max);
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The new version moves RxJS codes of number input field to the constructor, deletes OnInit interface and ngOnInit method. The application simplifies reactive codes; signals replaces btnPokemonId$, images$ and their logic. The final component codes are more concise than the prior version.

This is it and I have rewritten the Pokemon application to have both RxJS codes and Angular signals to coexist.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

💖 💪 🙅 🚩
railsstudent
Connie Leung

Posted on June 1, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related