Zašto je HTTP sloj u vašoj Angular aplikaciji loš?
Uvod
Napravio sam radionicu na ovu istu temu. GitHub repozitorij s radionicom možete pronaći ovdje.
Ako bi 2018. usporedili Angular s ostalim, u to vrijeme popularnim, frameworcima, Angular je izgledao kao svojevremeni meta-framework. U to vrijeme, jedina ozbiljna konkurencija su bili React i Vue, a Angular je bio jedini koji je dolazio s ugrađenim HTTP slojem. U to doba, Angular je taman napravio veliku tranziciju s AngularJS-a na Angular 2, React je prešao na funkcionalne komponente, a Vue 2 je postizao sve veću popularnost.
Tad, ali više-manje i danas, ako bismo htjeli napraviti HTTP poziv u Reactu, morali bi koristiti fetch
u efektu te postaviti rezultat u stanje komponente.
function Component() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => setData(data));
}, []);
if (!data) return <div>Loading...</div>;
return <div>{data}</div>;
}
Za razliku od React-a, Angular ima ugrađen HttpClient servis koji se koristi za slanje HTTP zahtjeva.
Angularov HttpClient
, kao u to vrijeme i cijeli Angular ekosustav, koristi RxJs za upravljanje asinkronim događajima.
Osim ugrađenog rješenja za HTTP zahtjeve, Angular dolazi s još dosta mogućnosti koje React nema. Angular ima vlastiti router, rješenje za forme, HTTP interceptore i još mnogo toga. React je do nedavno se promovirao samo kao view sloj u MVC arhitekturi, dok je Angular bio cijeli framework.
Zbog ovoga, za standarde 2018. godine možemo reći da je Angular bio sličniji modernom konceptu meta-frameworka nego React ili Vue. Ali ako danas Angular usporedimo s modernim meta-frameworcima kao SvelteKit, Remix ili Nuxt, Angular izgleda zastarjelo.
U ovom članku pokazat ću metode koje sam vidio da ljudi koriste za obavljanje HTTP zahtjeva u Angular-u i zašto smatram da su te metode loše. Pokazat ću kako ja smatram da bi se idealno trebali raditi HTTP zahtjevi u vanilla Angular-u, te na kraju ću pokazati zašto smatram da vanilla Angular nije dovoljan i kako bi se mogao poboljšati.
Primjer
Prvo ću pokazati primjer aplikacije na kojemu ću demonstrirati sve metode.
Pretpostavimo da imamo PokemonService
sljedećeg izgleda:
interface PokemonService {
getPokemon(id: number): Observable<Pokemon>;
getCurrentUser(): Observable<{user: User, favouritePokemon: Pokemon}>;
getAllPokemon(): Observable<Pokemon[]>;
setPokemonAsFavourite(id: number): Observable<void>;
}
Imamo aplikaciju s rutom /pokemon/:id
, koja ima tri komponente:
- UserComponent koja prikazuje trenutačnog korisnika i njegovog omiljenog pokemona
- PokemonListComponent koja prikazuje popis svih pokemona i omoguće otvaranje detalja pokemona
- PokemonDetailComponent koja prikazuju detalje pokemona i omogućuje postavljanje pokemona kao omiljenog
Varijabla id
u ruti je ID pokemona koji se trenutačno prikazuje. Kada korisnik klikne na Pogledaj
, mijenja se ruta na /pokemon/:id
gdje je id
ID pokemona koji je korisnik odabrao.
Odabirom Označi kao omiljenog
, pokemona s trenutačnim ID-om postavljamo kao omiljenog korisnika. Nakon čega želimo ažurirati komponente UserComponent
i PokemonListComponent da prikažu novog omiljenog pokemona.
Loše metode
Ovaj članak će razmatrati Angular 19 u SPA CSR aplikacijama. Smatram da Angular nije zreo za SSR aplikacije te postoje puno bolja rješenja za to.
Loše metode ću prikazati od najlošije do najbolje.
Rezultat upita kao property razreda komponente
Prva metoda koju ću pokazati je rezultat upita kao property razreda komponente. Ova je metoda je crta najmanjeg otpora, stoga je vrlo često koriste početnici u Angularu.
U ovoj metodi se unutar ngOnInit
metode komponente poziva metoda servisa koja vraća rezultat upita.
Pošto HttpClient
vraća Observable
, da bi pristupili rezultatu upita, moramo se pretplatiti na Observable
.
Za razliku od async/await
API-ja, RxJs
koristi callback
funkcije za pretplatu na subjekt.
Što znači da moramo izvući rezultat callback
funkcije i omogućiti pristup toj varijabli kroz template komponente.
To je najjednostavnije napraviti tako da se inicijalizira varijabla rezultata kao property razreda komponente u null, te se u callback
funkciji postavi rezultat upita u tu varijablu.
@Component({
selector: 'app-user',
template: `
<div *ngIf="user">
<h1>{{ user.user.name }}</h1>
<p>{{ user.favouritePokemon.name }}</p>
</div>
`
})
export class UserComponent implements OnInit {
private pokemonService = inject(PokemonService)
public user: {user: User, favouritePokemon: Pokemon} | null = null;
ngOnInit() {
this.pokemonService.getUser().subscribe(user => this.user = user);
}
}
@Component({
selector: 'app-pokemon-list',
template: `
<ul>
<li *ngFor="let pokemon of pokemonList">
<span>{{ pokemon.name }}</span>
<a [routerLink]="['/pokemon', pokemon.id]">Pogledaj</a>
</li>
</ul>
`
)}
export class PokemonListComponent implements OnInit {
private pokemonService = inject(PokemonService)
public pokemonList: Pokemon[] | null = null;
ngOnInit() {
this.pokemonService.getAllPokemon()
.subscribe(pokemonList => this.pokemonList = pokemonList);
}
}
@Component({
selector: 'app-pokemon-detail',
template: `
<h1>{{ pokemon.name }}</h1>
<button (click)="setAsFavourite()">Označi kao omiljenog</button>
`
})
export class PokemonDetailComponent implements OnInit {
private pokemonService = inject(PokemonService)
private route = inject(ActivatedRouteRoute)
private pokemonId = this.route.snapshot.params.id;
public pokemon: Pokemon | null = null;
ngOnInit() {
this.pokemonService.getPokemon(this.pokemonId)
.subscribe(pokemon => this.pokemon = pokemon);
}
setAsFavourite() {
this.pokemonService.setPokemonAsFavourite(this.pokemonId)
.subscribe(() => {
alert('Pokémon postavljen kao omiljeni');
});
}
}
Što tu ne valja? Zapravo, dosta toga.
Krenimo prvo od manje tehničkog problema. Gdje su nam indikatori učitavanja? Kako korisnik zna da se nešto učitava? Kako korisnik zna da je došlo do greške? Ovo je prvi problem koji se javlja kod ove metode. Kako bi se riješio ovaj problem, moramo dodati indikatore učitavanja i grešaka pored svakog rezultata upita.
Indikatori učitavanja i grešaka
Uzmimo za primjer samo UserComponent.
@Component({
selector: 'app-user',
template: `
@if (loading) {
<p>Loading...</p>
}
@if (error) {
<p>Error: {{ error }}</p>
}
@if (user) {
<h1>{{ user.user.name }}</h1>
<p>{{ user.favouritePokemon.name }}</p>
}
`
})
export class UserComponent implements OnInit {
private pokemonService = inject(PokemonService)
public user: {user: User, favouritePokemon: Pokemon} | null = null;
public loading = true;
public error = null;
ngOnInit() {
this.pokemonService.getUser().subscribe({
next: (user) => {
this.user = user;
},
error: (error) => {
this.error = error;
},
finalize: () => {
this.loading = false;
}
});
}
}
Ovo je već bolje, ali i dalje nije dovoljno dobro. Moramo se brinuti o svakom upitu posebno, što znači da ako u istoj komponenti imamo više upita, moramo čuvati loading
, error
i result
varijable za svaki upit posebno.
Promjena rute
Ako korisnik klikne na Pogledaj
u PokemonListComponent, ruta se mijenja na /pokemon/:id
.
Hoće li u našoj implementaciji PokemonDetailComponent automatski ažurirati prikaz novog pokemona?
Za nekoga tko nema dovoljno iskustva s Angularom, tj. s RxJs-om, tu bi se vrlo lako mogao zeznuti.
Mi smo pokemonId
uzeli iz trenutačne rute, ali samo kao snapshot. Što znači da ako se ruta promijeni, pokemonId
se neće promijeniti.
Za razliku od React-a, Angular ne reagira automatski na sve promjene u stanju komponente ili kontekstu.
Kako možemo onda reagirati na promjene rute? Isti servis koji koristimo za dohvaćanje podataka, možemo koristiti i za reagiranje na promjene rute.
private route = inject(ActivatedRouteRoute)
private pokemonId$: Observable<number> = this.route.params.pipe(
map(params => params.id),
filter(id => !!id),
map(id => parseInt(id))
);
S ovim smo dobili pokemonId$
koji je Observable<number>
i emitira novi ID pokemona svaki put kad se promijeni ruta.
Pošto više nemamo statički ID pokemona kao property klase, imamo novi problem. Sada trebamo reagirati na promjene ID-a pokemona i dohvatiti novog pokemona svaki put kad se promijeni ID.
To znači da mi želimo imati pokremon$: Observable<Pokemon>
koji ovisi o pokemonId$: Observable<number>
.
Sad dolazimo do prvog značajnijeg poboljšanja u našoj implementaciji - switchMap.
switchMap
služi da emitiranjem vrijednosti jednog Observable-a okinemo stvaranje novog Observable-a.
@Component({
selector: 'app-pokemon-detail',
template: `
@if (loading) {
<p>Loading...</p>
}
@if (error) {
<p>Error: {{ error }}</p>
}
@if (pokemon) {
<h1>{{ pokemon.name }}</h1>
<button (click)="setAsFavourite()">Označi kao omiljenog</button>
}
`
})
export class PokemonDetailComponent implements OnInit {
private pokemonService = inject(PokemonService)
private route = inject(ActivatedRouteRoute)
private pokemonId$: Observable<number> = this.route.params.pipe(
map(params => params.id),
filter(id => !!id),
map(id => parseInt(id));
public pokemon: Pokemon | null = null;
public loading = true;
public error = null;
ngOnInit() {
this.pokemonId$.pipe(
switchMap(id => this.pokemonService.getPokemon(id))
).subscribe(() => {
next: (pokemon) => {
this.pokemon = pokemon;
},
error: (error) => {
this.error = error;
},
finalize: () => {
this.loading = false;
}
});
}
setAsFavourite() {
this.pokemonService.setPokemonAsFavourite(this.pokemonId)
.subscribe(() => {
alert('Pokémon postavljen kao omiljeni');
});
}
}
PokemonDetailComponent nam je upravo postala dosta kompleksnija, ali puno robusnija. Stvorili smo graf ovisnosti asinkronih događaja, što znači da će se svaki put kad se promijeni ID pokemona, automatski dohvatiti novi pokemon. I to nam je super.
Neželjene nuspojave
Možda se na prvu ne čini tako, ali zadnji primjer PokemonDetailComponent je identičan React primjeru iz uvoda. Identični su po tome što oba primjera kreiraju nuspojavu (eng. side-effect).
Nuspojava u kontekstu web frameworka je događaj koji se okine kao reakcija na neki drugi događaj a da ne rezultira emitiranjem vrijednosti u neki state store.
Reaktivni procesi se mogu svesti na tri osnovne komponente:
- stanje
- izračunato stanje
- nuspojave
Izračunato stanje je stanje koje se računa svaki put kada se promijeni stanje o kojem ovisi. Razlika nuspojava i izračunatog stanja je u tome što nuspojava ne rezultira promjenom stanja, dok izračunato stanje rezultira promjenom stanja.
Primjer iz PokemonDetailComponent je nuspojava u funkcijama next
, error
i finalize
obavljamo neku funkciju kada iz pipe
-a dobijemo neki događaj.
pokemonId$
je izračunato stanje jer se svaki put kad se promijeni ruta, izračuna novi ID pokemona i spremi se u novo stanje bez da se izlazi van konteksta callback funkcije.
Ovaj primjer je anti-pattern, jer iz nuspojave utječemo na stanje van konteksta callback funkcije tako da postavljemo this.pokemon
, this.loading
i this.error
.
Ovakvi uzroci rezultiraju zloglasnom ExpressionChangedAfterItHasBeenChecked greškom.
Jedan dodatni i nevidljivi problem je što smo se pretplatili na pokemonId$
u ngOnInit
metodi, ali se nismo odjavili.
To znači da svi put kada se kreira komponenta, stvori se nova pretplata na pokemonId$
, ali se nikad ne odjavi. Ovo može rezultirati memory leak-om i nepotrebnim opterećenjem aplikacije.
Kako riješiti ovaj problem?
Ako se oslanjamo na RxJs za upravljanje asinkronim događajima, onda se trebamo u potpunosti osloniti na RxJs način programiranja. To znači da svaki očekivani asinkroni događaj moramo promatrati kao kao cijev kroz koju protječe informacija kao voda.
Nuspojavu u ovom kontekstu možemo zamisliti kao da smo ostavili otvorenu cijev da možemo promatrati što se događa u cijevi i reagirati na to.
Možda bi bolje bilo da umjesto otvorene cijevi, imamo zatvorenu cijev koja se račva ili spaja s drugim cijevima po potrebi? Bez potencijalnog iscurenja vode?
Ako se vratimo nazad u kontekst Angular-a i RxJs-a, to bi značilo da umjesto ručne pretplate na subjekt, rezultate iz upita sačuvamo kao Observable
property razreda komponente.
A na taj Observable
se pretplatimo u template-u koristeći async
pipe.
Ispravno vanilla Angular rješenje
@Component({
selector: 'app-pokemon-detail',
template: `
@if (loading$ | async) {
<p>Loading...</p>
}
@if (error$ | async; as error) {
<p>Error: {{ error }}</p>
}
@if (pokemon$ | async; as pokemon) {
<h1>{{ pokemon.name }}</h1>
<button (click)="setAsFavourite(pokemon)">Označi kao omiljenog</button>
}
`,
imports: [AsyncPipe]
})
export const PokemonDetailComponent {
private pokemonService = inject(PokemonService)
private route = inject(ActivatedRouteRoute)
private pokemonId$: Observable<number> = this.route.params.pipe(
map(params => params.id),
filter(id => !!id),
map(id => parseInt(id));
public loading$ = new BehaviorSubject<boolean>(true);
public error$ = new BehaviorSubject<Error | null>(null);
public pokemon$: Observable<Pokemon> = this.pokemonId$.pipe(
switchMap(id => {
this.loading$.next(true);
this.error$.next(null);
return this.pokemonService.getPokemon(id).pipe(
catchError(error => {
this.error$.next(error);
return EMPTY;
}),
finalize(() => this.loading$.next(false))
);
})
);
setAsFavourite(pokemon: Pokemon) {
this.pokemonService.setPokemonAsFavourite(pokemon.id)
.pipe(first()).subscribe();
.subscribe(() => {
alert('Pokémon postavljen kao omiljeni');
});
}
}
Što smo dobili s ovim rješenjem?
Imamo komponentu koja ispravno s promjenom rute dohvaća novog pokemona, sprema loading$
i error$
stanja, prikazuje ih u template-u bez da se mi moramo brinuti o pretplatama i životu komponente.
Strogo gledano, i dalje imamo nuspojave u switchMap
funkciji jer izlazimo van konteksta funkcije i postavljamo loading$
i error$
stanja.
Ali bar sada smo smanjili rizik ExpressionChangedAfterItHasBeenChecked
greške jer smo sve promjene stanja stavili u Subject
-e na koje se možemo pretplatiti u template-u koristeći async
pipe.
ExpressionChangedAfterItHasBeenChecked
se najčešće događa kada iz nuspojava mijenjamo property razreda komponente koji nije reaktivan (subjekt ili signal) te slučajno izlazimo van Angular-ovog ciklusa detekcije promjena.
Točnije, mi smo taman promijenili stanje komponente nakon što je Angular napravio provjeru promjena u stablu komponenti i napravio rerender. Ova greška je nešto slično greškama hidracije u React-u,
ali napornija jer se događa nepredvidivo i teško je reproducirati. Greške hidracije su dosta jasne. Najčešće smo formatirali datum u vremenskoj zoni servera, pa na klijentu u vremenskoj zoni klijenta,
pa sustav za hidraciju ne zna što je ispravno i odustane.
Zašto ovo i dalje nije dovoljno?
U ovoj rješenju ja vidim još dva problema:
1. Zastarjeli sustav reaktivnosti
Otkako je Angular uveo signale u verziji 16 kao alternativni sustav reaktivnosti koji treba zamijeniti RxJs
i Zone.js
,
svakom novom verzijom Angulara sve više i više core funkcionalnosti se prebacuju s RxJs
na signale
.
RxJs
i Zone.js
su neovisne biblioteke na koje se Angular dugo oslanjao.
Ali ove biblioteke su opće namjene. RxJs, kao primjer, ima ogromnu količinu funkcionalnosti koje su, velikom većinom, nepotrebne za većinu web aplikacija.
Koristeći RxJs kao sustav reaktivnosti, programer je prisiljen osim Angulara učiti i RxJs. Angular je dovoljno velik i kompleksan framework da se učenje RxJs-a čini kao nepotrebno opterećenje.
import {resource, Signal} from '@angular/core';
const userId: Signal<string> = getUserId();
const userResource = resource({
// Define a reactive request computation.
// The request value recomputes whenever any read signals change.
request: () => ({id: userId()}),
// Define an async loader that retrieves data.
// The resource calls this function every time the `request` value changes.
loader: ({request}) => fetchUser(request),
});
// Create a computed signal based on the result of the resource's loader function.
const firstName = computed(() => userResource.value().firstName);
U verziji 19, Angular je uveo resource kao alternativu za HttpClient
servis.
Inspirirano resource-om SolidJs-a,
Angularov resource prima async funkciju iz koje stvara resurs signal s rezultatom async funkcije zajedno s indikatorima učitavanja i grešaka.
Jedini problem je što je resource
još uvijek eksperimentalna funkcionalnost i nije preporučeno koristiti je u produkcijskim aplikacijama.
2. Invalidacija stanja
Ovaj problem je nešto manje tehničke prirode, ali možda i važniji od prethodnog.
Vratimo se na primjer naše aplikacije.
Kada korisnik klikne na Označi kao omiljenog
u PokemonDetailComponent, mi šaljemo zahtjev na server da postavimo pokemona s trenutnim ID-om kao omiljenog.
Nakon uspješne promjene favorita, UserComponent i PokemonListComponent
će zadržati neispravno stanje. Tj., imat će stanje koje su učitali u trenutku kreiranja komponente.
To nam nije nikako dobro. Želimo nekako invalidirati stanje komponenti nakon što se promjeni stanje na serveru.
Ako pogledamo moderne meta-frameworkove kao SvelteKit i Remix, oni imaju ugrađene mehanizme invalidacije stanja.
Ova dva meta-frameworka imaju koncepte loader
-a i action
-a koji se koriste za dohvaćanje podataka i promjenu stanja na serveru.
Nakon uspješnog poziva action
-a, stanje koje je dobiveno kroz loader
se automatski invalidira i komponente se ponovno renderiraju.
U našoj Angular aplikaciji moramo se sami pobrinuti o tome.
Pada mi nekoliko mogućih rješenja na pamet:
- Globalni event bus
- Kreiramo globalni servis koji sadrži
ReplaySubject<void>
koji trigeramo krozHttpInterceptor
nakon uspješnog HTTP POST, PUT ili DELETE zahtjeva. - To znači da prije svakog
HttpClient
poziva, moramo prvo se referencirati nainvalidate$
subjekt, te uswitchMap
-u pozvatiHttpClient
servis. - Ovo rješenje je dosta kompleksno i nije preporučeno za veće aplikacije.
- Također, ovo rješenje radi automatski za sve zahtjeve, što znači da bi se moglo dogoditi da se neka komponenta invalidira iako nije potrebno.
- Kreiramo globalni servis koji sadrži
- Lokalizirani event bus
- Slično kao globalni event bus, ali umjesto globalnog servisa, koristimo lokalizirani servis koji se koristi samo u komponentama koje trebaju invalidirati.
- Ovo rješenje ima mane i prednosti u odnosu na globalni event bus, ali je svakako kompliciranije od globalnog event bus-a.
Niti jedno od ovih rješenja nije idealno. Oba rješenja su komplicirana.
Tanner Linsley spašava stvar
Pošto React od početka nema ugrađeno rješenje za HTTP zahtjeve, React zajednica je morala pronaći rješenje za ovaj problem.
Tanner Linsley tada stvara React Query kao rješenje za upravljanje asinkronim događajima u React-u. To rješenje postaje ekstremno popularno. Većina ozbiljnih React aplikacija, ako se teško ne oslanja na neki meta-framework kao Remix, koristi React Query.
U određenom trenutku, Tanner zaključuje da njegova rješenja nisu primjenjiva samo na React, nego na sve moderne web frameworke. Tako React Query
postaje Tanstack Query
, a React dobiva adapter za Tanstack Query
.
Ubrzo su nastali adapteri za Vue
, Svelte
i SolidJs
, ali Angular je dugo bio bez adaptera.
Sve dok Angular nije uveo signale kao novi primitiv reaktivnosti. Angular zajednica je odmah počela raditi na adapteru za Tanstack Query
koji bi se oslanjao na signale.
Tako nastaje Angular Tanstack adapter.
Neću ulaziti u detalje zašto je Tanstack Query
jako dobar, jer je ta tema već dosta puta obrađena. Za one koje zanima više, preporučuje TkDoto-ov blog.
Njegovi primjeri se odnose na React adapter, ali API je 90% isti. Umjesto useQuery
koristimo injectQuery
i umjesto useMutation
koristimo injectMutation
. Ostalo je isto. Koristi se Tanstack Query
core API ispod haube.
Poanta korištenja Tanstack Query
je da umjesto da stvaramo vlastiti globalni event bus i rekreiramo Redux
, koristimo biblioteku kojoj vjeruju tisuće produkcijskih aplikacija i koja je već prošla sve moguće edge case-ove.
Ako smatrate da znate bolje do cijele zajednice front-end developera koji rade na ovom rješenju zadnjih 5 godina, onda samo naprijed.
Ja prepoznajem da nemam ni približno dovoljno iskustva da bih mogao stvoriti nešto bolje od Tanstack Query
, stoga ću se osloniti na ljude koji znaju što rade 😂.
Naše komponente s Tanstack Query
@Component({
selector: 'app-user',
template: `
@if (userQuery.isLoading()) {
<p>Loading...</p>
}
@if (userQuery.isError()) {
<p>Error: {{ userQuery.error() }}</p>
}
@if (userQuery.isSuccess()) {
<h1>{{ userQuery.data().user.name }}</h1>
<p>{{ userQuery.data().favouritePokemon.name }}</p>
}
`
})
export const UserComponent {
private pokemonService = inject(PokemonService)
private queryClient = injectQueryClient();
public userQuery = injectQuery(() => ({
queryKey: ['user'],
queryFn: () => lastValueFrom(this.pokemonService.getCurrentUser()),
}))
}
@Component({
selector: 'app-pokemon-list',
template: `
@if (pokemonListQuery.isLoading()) {
<p>Loading...</p>
}
@if (pokemonListQuery.isError()) {
<p>Error: {{ pokemonListQuery.error() }}</p>
}
@if (pokemonListQuery.isSuccess()) {
<ul>
<li *ngFor="let pokemon of pokemonListQuery.data()">
<span>{{ pokemon.name }}</span>
<a [routerLink]="['/pokemon', pokemon.id]">Pogledaj</a>
</li>
</ul>
}
`
})
export const PokemonListComponent {
private pokemonService = inject(PokemonService)
private queryClient = injectQueryClient();
public pokemonListQuery = injectQuery(() => ({
queryKey: ['pokemon', 'list'],
queryFn: () => lastValueFrom(this.pokemonService.getAllPokemon()),
}))
}
@Component({
selector: 'app-pokemon-detail',
template: `
@if (pokemonQuery.isLoading()) {
<p>Loading...</p>
}
@if (pokemonQuery.isError()) {
<p>Error: {{ pokemonQuery.error() }}</p>
}
@if (pokemonQuery.isSuccess()) {
<h1>{{ pokemonQuery.data().name }}</h1>
<button (click)="setAsFavourite(pokemonQuery.data())">Označi kao omiljenog</button>
}
`
})
export const PokemonDetailComponent {
private pokemonService = inject(PokemonService)
private queryClient = injectQueryClient();
private pokemonId: Signal<number> = injectParam<number>('id');
public pokemonQuery = injectQuery(() => ({
queryKey: ['pokemon', this.pokemonId()],
queryFn: () => lastValueFrom(this.pokemonService.getPokemon(this.pokemonId)),
}))
public favouriteMutation = injectMutation(() => ({
mutationKey: ['favourite'],
mutationFn: (pokemon: Pokemon) => lastValueFrom(this.pokemonService.setPokemonAsFavourite(pokemon.id)),
onSuccess: () => {
this.queryClient.invalidateQueries({queryKey: ["pokemon"]]});
this.queryClient.invalidateQueries({queryKey: ["user"]]});
}
}))
setAsFavourite(pokemon: Pokemon) {
this.favouriteMutation.mutate(pokemon);
}
}
injectParams()
je pretpostavka da smo napravili custom hook koji koristi AngularActivatedRoute
za dohvaćanje parametara rute i vraća ih kaoSignal
.
Što smo dobili s ovim rješenjem?
Uzmimo za primjer samo PokemonDetailComponent.
Umjesto vlastitog praćenja rezultata, indikatora učitavanja i grešaka, mi koristimo injectQuery
koji nam riješi sve te probleme automatski.
Kao rezultate te funkcije, dobijemo objekt koji sadržava funkcije:
data()
koja vraća rezultat upitaisLoading()
koja vraća je li upit u tijekuisError()
koja vraća je li došlo do greškeerror()
koja vraća greškuisSuccess()
koja vraća je li upit uspješanrefetch()
koja ponovno pokreće upitinvalidate()
koja invalidira upit … i još mnogo toga.
Invalidacija nakon akcije
Jesmo li uspjeli riješiti problem invalidacije stanja nakon akcije s novim rješenjem? Da!
U funkciji setAsFavourite
, ne pozivamo direktno setPokemonAsFavourite(pokemon.id)
, nego koristimo favouriteMutation
.
favouriteMutation
je objekt koji smo dobili iz injectMutation
funkcije. Ovaj objekt zaokružuje funkciju setPokemonAsFavourite
i dodaje mu dodatne funkcionalnosti.
Slično kao i za injectQuery
, injectMutation
vraća objekt s funkcijama kao isLoading
, isError
, error
, isSuccess
i sl., ali nama je trenutno bitna funkcija mutate
i callback onSuccess
.
mutate
će jednostavno pozvati funkciju koju definiramo u mutationFn
s parametrima koje joj damo.
Zanimljivi dio dolazi u onSuccess
callback-u. Ovdje možemo definirati što će se dogoditi nakon uspješnog poziva mutationFn
.
U našem slučaju, nakon što se pokemona uspješno postavi kao omiljenog, želimo označiti sve naše upite za neispravne te želimo ih ponovno pokrenuti.
Pošto je Tanstack Query
kompletan alat za upravljanje asinkronim događajima, on zna kako invalidirati upite i ponovno ih pokrenuti.
Jednostavno trebamo pozvati naš queryClient
i reći mu koje upite želimo invalidirati.
Pošto svaki upit mora imati definiran ključ, mi mu jednostavno kažemo da želimo invalidirati upit s ključem pokemon
i upit s ključem user
.
Tanstack Query
ima širok spektar mogućnosti za invalidaciju upita, ali ovo je najjednostavniji način koji je u dosta slučajeva dovoljan.
Za one koji žele znati više
Kao resurs za učenje Tanstack Query
-a, preporučujem službenu dokumentaciju, ali i TkDoto-ov blog koji je već spomenut.
Tanstack Query je jako dobro dokumentiran te ima jako veliku zajednicu korisnika. Većina primjera na internetu će biti u React-u, ali API je isti za sve adaptere.
Ako aplikacija postane dovoljno velika, održavanje ključeva upita može postati izvor glavobolje. Srećom, Tanstack Query ima jako dobar Devtools koji nam pomaže u praćenju upita i invalidaciji upita.
Jedan jako koristan alat koji bih preporučio je @hey-api/openapi-ts. Ovaj alat služi za generiranje TypeScript klijenata iz OpenAPI specifikacija.
Ima adapter za Tanstack Query Angular klijent, pa vam generira funkcije kao getPokemonByIdOptions()
i getPokemonByIdQueryKey()
.
S ovim korisnim funkcijama, invalidacija podataka jednom Pokemona može izgledati ovako:
this.queryClient.invalidateQueries({queryKey: getPokemonByIdQueryKey({
path: {
id: this.pokemon().id
}
})});
Zaključak
U ovom članku smo prošli od rješenja koji koristi nuspojave da postavi property klase te uzrokuje ExpressionChangedAfterItHasBeenChecked
grešku,
do rješenja koji se jako oslanja na RxJs
i “funkcionalni stil programiranja”, pa sve do rješenja koji koristi moderne uzorke s Tanstack Query
-om i signal
-ima.
Angular se, po meni, trenutno nalazi na jako čudnom mjestu. U isto vrijeme ima nogu na tri mjesta.
- Ljudi vrlo često koriste property klase da čuvaju stanje, čime se oslanjaju na
Zone.js
iChangeDetection
mehanizme koji su jako spori i neefikasni. A najiritantnija greška koju Angular developer može zaprimiti jeExpressionChangedAfterItHasBeenChecked
, a s ovim obrazcem je neizbježna. RxJs
je motor koji pokreće asinkrone događaje u Angularu, ali je jako kompleksan i težak za naučiti koristiti ispravno.- Signali se uvode da bi se uskladili s modernijim frameworcima kao
SolidJs
iSvelte
.
Ja osobno ne bih odabrao Angular za novi projekt. Smatram da dok se skroz ne izbaci RxJs
i Zone.js
iz core-a, Angular je nepotrebno komliciran i težak za održavanje.
Za izradu jedne web aplikacije u Angularu, potrebno je puno više znanja specifičnog Angularu neko ako bi se koristio npr. Svelte
ili SolidJs
koji se više oslanjaju na web standarde.
Na poslu moram koristiti Angular, zato želim olakšati posao sebi ali i svojim kolegama, a Tanstack Query
je alat koji savršeno postiže balans između jednostavnosti i mogućnosti.
Jednostavno ne želim održavati vlastiti globalni event bus i ne želim koristiti obrasce Redux
-a kroz ngrx
(sami Dan Abramov, izumitelj Redux-a, potiče ljude da ne koriste Redux ako nemaju jako dobar razlog za to).
Nemam problema s korištenjem RxJs
-a, ali to je jako kompleksna biblioteka koju ljudi često koriste na krivi način.
Stoga, ako ste u dilemi kako upravljati asinkronim događajima u Angularu, preporučujem Tanstack Query
kao rješenje koje će vam uštedjeti puno vremena i živaca.