Współdziałanie sygnałów i RxJS w Angularze na praktycznym przykładzie
Sygnały w Angularze to nowy reactive primitive, który usprawni sposób w jaki tworzymy aplikacje w angularze ora zpoprawi Developer Experience. Znacząco również wpłynie na mechanizm detekcji zmian.
W tym artykule wyjaśnię, jak utworzyć komponent oparty na sygnałach. Dzięki praktycznemu przykładowi dowiesz się:
- Jak przekształcić RxJS observable na sygnał
- Jak przekształcić sygnał na observable
Wszyscy lubimy wizualizacje, a więc zobaczmy, co zamierzamy stworzyć.
Poniższy kod jest naszym punktem wyjścia. Usprawnimy go wprowadzając koncepcję
sygnałów.
<div class="page-container">
<mat-form-field class="page-container--form-field">
<mat-label>Enter User Id (Empty will fetch all)</mat-label>
<input matInput type="text" [matAutocomplete]="autoComplete" />
<mat-spinner
*ngIf="false"
matSuffix
class="page-container--spinner"
></mat-spinner>
<mat-autocomplete #autoComplete="matAutocomplete">
<mat-option> Option 1 </mat-option>
<mat-option> Option 2 </mat-option>
<mat-option> Option 3 </mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
Konwersja z RxJS observable na sygnał
Zacznijmy od pobrania danych z serwisu, przekształcenia go do sygnału i przeiterowania
przez elementy tworzące opcje autocomplete’a.
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, delay, throwError } from 'rxjs';
import { Post } from './post.type';
@Injectable({
providedIn: 'root',
})
export class PostsService {
private http = inject(HttpClient);
get(userId?: number): Observable<Post[]> {
if (userId == 100) {
return throwError(() => new Error('User not found'));
}
return this.http
.get<Post[]>('https://jsonplaceholder.typicode.com/posts', {
params: {
...(userId ? { userId: userId.toString() } : {}),
},
})
.pipe(delay(2000));
}
}
Metoda <span style="font-weight: 400;">get</span><span style="font-weight: 400;">()</span>
:
- Przyjmuje userId jako argument
- Wyrzuca błąd jeżeli userId wynosi 100
(to jest tylko manualny sposób na rzucenie błędu abyśmy mogli zobaczyć jak zaaplikować podstawową obsługę błędów) - Stosuje sztuczny delay, aby umożliwić wyświetlenie loader’a
Tak jak wspomniałem powyżej, przejdziemy przez to wszystko krok po kroku.
W poniższym kodzie, wykorzystujemy metodę toSignal
, która przyjmuje observable i zwraca
sygnał oparty na wartościach otrzymanych z observable. Warto zaznaczyć, że subskrypcja do observable zarządzania
jest automatyczna, oraz jest czyszczona kiedy injection context jest niszczony.
@Component({...})
export class PostsComponent {
private postsService = inject(PostsService);
posts = toSignal(this.postsService.get());
}
Przeiterujmy teraz po obiektach Post w szablonie HTML. Zauważ, że musimy użyć nawiasów,
aby uzyskać wartości z sygnału. Może się to wydawać złym podejściem gdyż uczyliśmy się, że metody w szablonie
wywoływane są przy każdym cyklu detekcji zmian, jednakże nie dotyczy to sygnałów.
<mat-autocomplete #autoComplete="matAutocomplete">
<mat-option *ngFor="let post of posts()" [value]="post.title">
{{ post.title }}
</mat-option>
</mat-autocomplete>
Jak dotąd udało nam się wykonać pierwszy krok wykorzystując jedynie metodę toSignal
. Spróbujmy teraz uzyskać dane wejściowe użytkownika i przesłać je do serwisu.
Konwersja sygnału na observable
Powiązanie Binding do inputa również będzie oparty na sygnałach. Nazwiemy go userId
, którego
wartość początkowa wynosić będzie undefined
.
export class PostsComponent {
private postsService = inject(PostsService);
userId = signal<number | undefined>(undefined);
posts = toSignal(this.postsService.get());
}
Musimy zastosować two-way data binding, ale jako że jeszcze nie możemy wykorzystać jego
składni, oddzielimy property binding wykorzystując event binding. (Podczas pisania artykułu wersja Angulara wynosi 16.0.4)
<input
[ngModel]="userId()"
(ngModelChange)="userId.set($event)"
matInput
type="text"
[matAutocomplete]="autoComplete"
/>
Aby wysłać żądanie HTTP za każdym razem, kiedy wartość userID się zmieni, możemy wykorzystać kombinacje efektu oraz BehaviourSubject
, tak jak widać poniżej.
import { effect, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BehaviorSubject } from 'rxjs';
@Component({...})
export class PostsComponent {
private postsService = inject(PostsService);
userId = signal<number | undefined>(undefined);
userId$ = new BehaviorSubject<number | undefined>(undefined);
constructor() {
this.userId$
.pipe(
// Do something here,
takeUntilDestroyed()
)
.subscribe();
effect(() => {
this.userId$.next(this.userId());
});
}
}
Podejście to działa prawidłowo, jednak wymaga od dewelopera zarządzania subskrypcją.
Wydaje się jednak, że jest to wzorzec, którego możemy użyć za każdym razem, gdy łączymy sygnał z niektórymi operatorami RxJS. Z tego względu zespół Angulara stworzył metodę toObservable
, która przekształca sygnał do observable oraz automatycznie zarządza jego subskrypcją. Dzięki tej metodzie, kod znacząco się skróci.
export class PostsComponent {
private postsService = inject(PostsService);
userId = signal<number | undefined>(undefined);
private posts$ = toObservable(this.userId).pipe(
switchMap((userId) => this.postsService.get(userId))
);
posts = toSignal(this.posts$);
}
W powyższym kodzie mamy pole posts$
, który ma bardzo krótki cykl życia, gdyż jest przekształcany na sygnał za pomocą metody toSignal
. Poprawmy to trochę i użyjmy operatora debounceTime
.
export class PostsComponent {
private postsService = inject(PostsService);
userId = signal<number | undefined>(undefined);
posts = toSignal(
toObservable(this.userId).pipe(
debounceTime(500),
switchMap((userId) => this.postsService.get(userId))
)
);
}
Jako że przekonwertowaliśmy sygnał do observable, możemy zastosować operatory RxJS do obsługi stanu ładowania wraz z obsługą błędów. Stan ładowania obsługujemy za pomocą sygnału isLoading signal<boolean>.
export class PostsComponent {
private postsService = inject(PostsService);
isLoading = signal<boolean>(false);
userId = signal<number | undefined>(undefined);
posts = toSignal(
toObservable(this.userId).pipe(
debounceTime(500),
tap(() => this.isLoading.set(true)),
switchMap((userId) =>
this.postsService.get(userId).pipe(catchError(() => of([])))
),
tap(() => this.isLoading.set(false))
)
);
}
I to wszystko!
Na koniec podzielę się kilkoma sugestiami:
- Postaraj się nie wykorzystywać async pipe’a w szablonie HTML, zwiększy to ilość cykli detekcji zmian
- Spróbuj przekształcić stan twojego komponentu do sygnału
- Nie obawiaj się korzystania z operatorów RxJS
- Postaraj się wykorzystywać efekty w celu tworzenia logów bądź przy manipulacji DOM.
Możesz również obejrzeć film opisujący przedstawiony przykład na moim kanałe Youtube:
Learn Angular Signals RxJS Interop From a Practical Example
Dziękuję za przeczytanie mojego artykułu.