Connie Leung
Posted on June 1, 2023
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 }))
);
}
}
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);
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>
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>
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);
});
}
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>
After (Signal)
<div>
<label>Pokemon Id:
<span>{{ currentPokemonId() }}</span>
</label>
<div class="container">
<img [src]="imageUrls().front" />
<img [src]="imageUrls().back" />
</div>
</div>
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`
}));
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,
}))
);
}
After (Signal)
<input type="number"
[ngModel]="searchIdSub.getValue()"
(ngModelChange)="searchIdSub.next($event)"
name="searchId" id="searchId" />
[(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));
}
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);
)
}
}
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:
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
October 30, 2024
October 22, 2024
September 30, 2024
September 24, 2024