Prebacio sam Angular aplikaciju na signale pa vratio na RxJs

Uvod
U listopadu prošle godine počeo sam raditi na novom projektu, do sada najvećem projektu u kojem sam sudjelovao. Timu sam se pridružio u ranoj fazi te u toj fazi smo imali priliku eksperimentirati s različitim tehnologijama i pristupima.
Backend aplikacije je napravljen u SpringBoot-u iz kojeg se generira OpenAPI specifikacija.
U trenutku kada sam se pridružio timu, frontend aplikacija je imala konfigurirano generiranje klijentske biblioteke iz OpenAPI specifikacije. Klijentska biblioteka je generirana pomoću openapi-generator koristeći adapter za Angular.
Generirani kod se sastojao iz servisa imenovanih po tag-ovima iz OpenAPI specifikacije (npr. UserService, OrderService, …).
Ti servisi sadrže metode koje odgovaraju HTTP endpointima iz specifikacije (npr. getUserById
, createOrder
, …)
te vraćaju Observable objekte.
Nisam najveći obožavatelj Angular-a, ali mi se svidjelo što Angular maintaineri pokušavaju unijeti neke moderne koncepte u Angular aplikacije - prvenstveno signale.
Izgledalo mi je kao da Angular ide u smijeru distanciranja od RxJs-a i prema signalima (kao i razni drugi frameworkovi u posljednje vrijeme, npr. Svelte, SolidJs, Preact itd.). Stoga sam istraživao mogućnosti rada s HTTP zahtjevima koristeći signale umjesto RxJs-a.
Resursi
Otprilike u to doba, Angular je izbacio v19
u kojem su predstavljeni resursi.
Kao velik obožavatelj SolidJs-a, primjetio sam odmah sličnosti s createResource
funkcijom.
Resursi primaju dependency listu signala i loader funkciju koja očekuje Promise
kao rezultat te vraća Resource
objekt
koji sadrži value
, error
i isLoading
signale, te jako korisnu reload
funkciju.
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);
Također postoji i rxResource
utility funkcija koja u loader
funkciju prima Observable
umjesto Promise
.
Jako zgodan RxJs
interop.
Dva velika problema koja sam imao s ovim pristupom su bila:
- tek je experimental feature
- priča oko akcija/mutacija je nepostojeća
Činjenica da je tek experimental feature mi nije smetala previše. Korisio sam signale u Angular-u dok su bili eksperimentalni i njihov API se minimalno izmjenio. Plus s Typescript-om, prilično sam siguran da bih u par sati mogao ispraviti sve potrebne promjene ako bi do njih došlo.
No, priča oko akcija/mutacija je bila problematična. Angular resursi se fokusiraju samo na učitavanje podataka, a ne i na manipulaciju podacima. Što bi značilo da bih morao implementirati svoj vlastiti sustav akcija/mutacija ili miješati resurse i RxJs.
Plus, tu je i problem automatskog generiranja klijentske biblioteke iz OpenAPI specifikacije, jer ne
postoji još uvijek adapter za Angular resurse (bar koliko ja znam).
Mogao bih koristiti generirane servise koji vraćaju Observable i wrappati svaki poziv koristeći
rxResource
, ali mislim da bi to bilo previše overheada.
Tanstack Query
Tanstack Query sam korstio u React-u, SolidJs-u ali i u Angular-u. Dobro sam upoznat s njim i jako mi se sviđa njihov pristup. Core library-ja je framework-agnostičan te je API skoro identičan u svim frameworcima.
Pronašao sam način generiranja Tanstack query helpera iz OpenAPI specifikacije, testirao kako radi i kakav je osjećaj. Bio sam jako zadovoljan, jer je API bio vrlo jednostavan i intuitivan.
const userId = signal(123);
const userQuery = injectQuery(() => ({
...getUserByIdOptions({
path: {
id: userId()
}
})
}))
userQuery.data(); // Dohvaća podatke
userQuery.isPending(); // Provjerava je li zahtjev u tijeku
userQuery.error(); // Dohvaća grešku
Super jednostavno i intuitivno.
Pokazao sam kolegama te ih nagovorio da probamo koristiti Tanstack Query dva tjedna. Ako se većini ne svidi, vratiti ću aplikaciju na prijašnje stanje. Bili smo u dosta ranoj fazi projekta, pa smo mogli priuštiti takav eksperiment.
Tada sam napisao članak Zašto je HTTP sloj u vašoj Angular aplikaciji loš? u kojem sam objasnio zašto mislim da je Tanstack Query bolji pristup od RxJs-a.
Na istu temu sam održao radionicu u kojoj sam kolegama u timu, a i ostalima zainteresiranim, pokazao kako koristiti Tanstack Query u Angular aplikacijama.
Na radionici, neki su izrazili zabrinutost oko toga što je Angular adapter za Tanstack Query još uvijek imao experimental
status.
Ali dobili smo dopuštenje još neko vrijeme eksperimentirati s tim.
Radili smo tri mjeseca s Tanstack Query-em te je napokon došlo vrijeme da se odlučimo hoćemo li nastaviti koristiti Tanstack Query ili ćemo se vratiti na RxJs.
Povratak na RxJs
Zabrinutost s trenutnim pristupom
Najveća dva problema koja su unosila zabrinutost u timu su bila:
experimental
status adaptera- odmicanje od Angular-ovog standardnog pristupa
Iskreno sam se nadao da će u tom periodu Tanstack Query adapter za Angular odbaciti experimental
status,
ali to se još nije dogodilo. Usprkos tom statusu, API je ostao stabilan i nije bilo breaking promjena.
No, odmicanje od Angular-ovog standardnog pristupa je bilo veći problem.
U potpunosti mi je jasna zabrinutost oko toga. Imaš ekipu Angular developer-a koja je navikla na jedan pristup, a onda dolazim ja i predlažem nešto potpuno drugačije. Želiš omogućiti da ubacivanjem novog developera u tim, on može brzo početi raditi na projektu.
Ubacivanje još jednog sloja apstrakcija, pogotovo na jednom tako bitnom dijelu aplikacije kao što je HTTP sloj, može biti problematično. To znači da svaki novi developer mora naučiti novi pristup. Također, to znači da imaš stariju aplikaciju koja je napisana na jednom pristupu, a novi kod koji je napisan na drugom pristupu.
Uz to, postojala je i zabrinutost oko održavanja Tanstack Query adaptera za Angular. Po meni, Tanstack ekipa je jako dobra i vjerujem da bi se pobrinuli za adapter, ali razumijem zabrinutost.
Na kraju je došla odluka - vratiti ćemo se na RxJs.
Refaktoriranje
Pošto sam ja taj koji je unio Tanstack Query u projekt, odlučio sam preuzeti odgovornost za refaktoriranje. Također, htio sam postaviti primjere za svaki slučaj korištenja Tanstack Query-a kako bih olakšao kolegama daljnji razvoj.
Plan je bio odraditi refaktoriranje tako da što veći dio koda prebacim na RxJs, ali ne nužno cijelu aplikaciju. Što više koda prebacim na RxJs, to bolje, ali moram minimalno postaviti primjer za svaki slučaj korištenja koji smo imali s Tanstack Query-em. Tako da ako ne stignem prebaciti cijelu aplikaciju, kolege mogu nastaviti s refaktoriranjem oslanjajući se na te primjere.
Sprint nam je završavao u petak, tako da sam imao savršenu priliku za refaktoriranje.
Repository je bio prilično čist jer su svi u timu bili svjesni da slijedi veliko refaktoriranje,
ali i pošto je bio kraj sprinta, većina je bila gotova s trenutnim zadacima i sve je merge-ano u main
granu.
Uspio sam refaktorirati cijelu aplikaciju preko vikenda, što je bio jako dobar osjećaj, i u potpunosti izbaciti Tanstack Query iz aplikacije.
Nisam bio zadovoljan ovim refactoringom, jer smatram da je to bio korak nazad.
Što mi fali iz Tanstack Query-a
Sve što ću navesti su stvari koje mogu implementirati sam, ali onda izmišljam toplu vodu. Već sam u navedenom članku objasnio zašto mislim da je Tanstack Query bolji pristup od RxJs-a. Ovo su stvari koje mi najviše nedostaju iz Tanstack Query-a:
1. Signal-based dependency tracking
Kako se Angular sve više pomiče u smijeru signala, logično je da bi i mi trebali pratiti taj smijer. Preporuke za održavanje reaktivnog komada stanja je da se baci u signal. Taj signal se može koristiti u template-u, lako se piše u njega te lako se kreira novi signal koji ovisi o njemu.
Pošto je Tanstack Query signal-based, bilo je jako lako pratiti ovisnosti između signala.
const userId = signal(123);
const userQuery = injectQuery(() => ({
...getUserByIdOptions({
path: {
id: userId()
}
})
}))
Ako se userId
promijeni, userQuery
će se automatski refetchati.
Ako prepišemo ovaj primjer u RxJs, to bi ovako izgledalo:
// Varijanta 1
const userId = signal(123);
const userId$ = toObservable(userId);
// Varijanta 2
const userId$ = new BehaviorSubject(123);
const user$ = userId$.pipe(
switchMap(id => this.userService.getUserById(id))
)
Ova promjena efektino znači da hrpa state-a koji su bili u signalima sada moraju biti u BehaviorSubject-ima - korak unazad.
2. Automatsko praćenje grešaka
Tanstack Query automatski prati greške i loading stanje.
Kada se dogodi greška, greška se automatski sprema u error
signal.
Ovo se naravno može implementirati i s RxJs-om, ali je puno više boilerplate koda.
const userId$ = new BehaviorSubject(123);
const error$ = new BehaviorSubject(null);
const loading$ = new BehaviorSubject(false);
const user$ = userId$.pipe(
tap(() => loading$.next(true)),
switchMap(id => this.userService.getUserById(id).pipe(
catchError(err => {
error$.next(err);
return of(null);
}),
finalize(() => loading$.next(false))
))
)
Naravno, možemo napraviti neki wrapper oko ovoga, ili čak custom pipe
operator, ali to je dodatni overhead - izmišljanje tople vode.
3. Beskonačni query-ji
Ovo je feature koji mi najviše nedostaje.
Koristeći Tanstack Query injectInfiniteQuery
možemo vrlo jednostavno kreirati resurs koji:
- ovisno o dependency listi resetira stanje
- ima mogućnost dohvata sljedeće i/ili prethodne stranice
- automatski prati loading i greške
- prati loading stanja po stranicama
- agregira podatke iz svih stranica
const userId = signal(123);
const friendsQuery = injectInfiniteQuery(() => ({
...getFriendsOptions({
path: {
id: userId()
}
}),
getNextPage: (page) => {...},
initialPageParam: 0
});
friendsQuery.data(); // Lista stranica koje treba flatMap-ati
friendsQuery.isPending(); // Provjerava je li zahtjev u tijeku
friendsQuery.isFetchingNextPage(); // Provjerava je li zahtjev za sljedećom stranicom u tijeku
friendsQuery.hasNextPage(); // Provjerava ima li još stranica
// i najbitnije
friendsQuery.fetchNextPage(); // Fetcha sljedeću stranicu
Jako, jako koristan feature.
Ovo sam morao sam implementirati koristeći RxJs. Vjerojatno sam stvorio nekoliko bug-ova tu, ali druge opcije nisam imao. Jako mi je bitan ovakav API jer je dosta koristan.
4. Stale-while-revalidate
Jedan jako zgodan feature, je stale-while-revalidate
strategija.
Ova strategija vraća podatke iz cache-a, a u pozadini fetcha nove podatke. Kada se novi podaci fetchaju, stari podaci se zamjenjuju s novima.
Primjer:
- odemo na stranicu profila korisnika
- dohvatimo podatke o korisniku, prikazuje se loader
- prikažu se podaci o korisniku
- odemo na neku drugu stranicu
- vratimo se na stranicu profila korisnika
- nema loadera, prikazuju se stari podaci, a u pozadini se dohvaćaju svježi podaci
Bez Tanstack Query-a
S Tanstack Query-em
Nije previše bitno, ali dosta zgodno da se ne mora uvijek prikazati loader.
S ovim feature-om, možemo i granularno za određene query-je odlučiti i na vrijeme
koliko će podaci biti stari prije nego se fetchaju novi.
Tako npr., ako imamo neke šifarnike koji se rijetko mijenjaju, možemo postaviti da se staleTime=Infinity
.
Onda nećemo morati ponovno dohvaćati taj podatak dok smo god u trenutnoj sesiji.
5. Ponovljeni zahtjevi
Može se naravno napraviti i s RxJs-om koristeći Angular HTTP interceptore, ali treba imati puno više znanja o RxJs-u i retry strategijama.
Dok se s Tanstack Query-jem to može i granularno kontrolirati za svaki query.
6. Granularno invalidiranje query-ja
Ovo mi je jedan od feature-a koji mi najviše nedostaje.
Recimo npr. da imamo komponentu koja prikazuje ime i prezime korisnika u kartici u header-u.
Negdje skroz dolje ispod imamo komponentu koja omogućuje korisniku da promijeni ime i prezime. Po uspješnoj promjeni, želimo da se ime i prezime u header-u automatski promijene.
Koristeći Tanstack Query, imamo dva načina kako bi mogli to napraviti:
- invalidirati query kartice koristenji njen
queryKey
- time se u pozadini refetcha taj query
- postaviti unaprijed poznate promjene u cache tog query-ja
Ovime možemo granularno kontrolirati što i kada se refetcha.
Neke stvari možemo ažurirati na view-u bez request-a (jer npr. POST req nam vraća ažurirane podatke u odgovoru), a za neke nam je jednostavije invalidirati cache i refetchati podatke.
Ali imamo usku kontrolu nad što točno invalidiramo i kada.
Koristeći vanilla Angular i RxJs, imamo par načina kako to napraviti (i niti jedan od njih mi se ne sviđa).
- Možemo imati po query-ju neki refresh subject koji će se emitati kada želimo da se refetcha query.
- Možemo imati neki globalni refresh subject koji će se emitati kada želimo da se refetcha bilo koji query.
Prva metoda podrazumjeva puno boilerplate koda. Neki hardcode “dosadašnji” Angular developeri bi možda čak napravili novi servis po query-ju ili po grupi query-ja (npr. UserCacheService, OrderCacheService, …).
Druga metoda je bolja (jer je jednostavnija), ali onda gubimo granularnu kontrolu nad time što se refetcha.
Po uspješnoj akciji kažemo globalRefreshSubject.next()
i svi query-ji se refetchaju.
S tim da bi onda svkaki query koji želi reagirati na globalni refresh morao izgledati ovako nekako:
const globalRefresh$ = new ReplaySubject<void>(1);
const filter$ = new BehaviorSubject(null);
const users$ = combineLatest([filter$, globalRefresh$]).pipe(
switchMap(([filter]) => this.userService.getUsers(filter)),
)
Ne nužno ReplaySubject
, ali nešto slično (startWith
, BehaviorSubject
, …).
Zaključak
Uz sve svoje prednosti, smatram da je Angular zaostao u usporedbi s drugim modernim frameworcima. Trenutno se nalazi u prijelaznom periodu, i zbog toga je malo “neudoban”. Iako je uvedeno dosta novih značajki (najviše mislim da signale), u dosta slučajeva ih ne mogu ili ne želim koristiti jer ne želim miješati signale i RxJs.
Nadam se da će kroz sljedeću godinu dana signali postati primarni način za rad na Angular-u. Čak i ako bude tako, mislim da će većina postojećih aplikacija ostati i dalje u svijeti RxJs-ja, jer ne vrijedi refactoring-a te miješanje signala i Observable-a nije najbolje iskustvo.
Iskreno, Angular u trenutnom stanju nije moj primarni izbor, niti bih ga preporučio drugima. Ali ne smeta mi raditi s Angular-om.