Uczysz się zarządzać stanem za pomocą NgRx? jeśli tak, to ten wpis jest skierowany do Ciebie. Nie będę obajśniał elementów NgRx, które pojawiają się w każdym tutorialu, czyli czym jest Redux i jaki jest flow, ani podstaw biblioteki NgRx. Zachęcam do poczytania dokumentacji: www.ngrx.io.
NgRx jest poteżnym narzędziem, ale niestety rzuca wiele kłód pod nogi programisty/stki. Zwłascza, jeśli nasze umiejętności w RxJS nie są na najwyższym poziomie. Podzielę się z Tobą wiedzą w kwestiach, które mogą być dla Ciebie problematyczne w pierwszych tygodniach pracy.
1. Kształtowanie Store
Pierwsza zagadka – jak trzymać dane dla dużych kolekcji? czy tablica? czy obiekt z kluczami?
ŹLE:
1 2 3 4 5 6 7 8 9 |
export interface State { cars: Car[]; } // state.cars [ { id: 123, model: 'Mercedes Benz' }, { id: 456, model: 'BMW' } ]; |
- w przypadku kolekcji, aby wyciągnąć jeden samochód z listy po ID, będziemy musieli przeszukać całą kolekcję, np. za pomocą metody Array.find (złożoność obliczeniowa O(N)):
1 |
getCar = (id: number) => cars.find(car => car.id === id); |
DOBRZE:
1 2 3 4 5 6 7 8 9 |
export interface State { cars: {[id: string]: Car}; } // state.cars { 123: { id: 123, model: 'Mercedes Benz' }, 456: { id: 456, model: 'BMW' } } |
- Lepszym rozwiązaniem jest trzymać encje w obiekcie, gdzie kluczami są odpowiadające ID. Dzięki temu wydajniej wyszukujemy poszczególne elementy (złożoność obliczeniowa O(1)):
1 |
getCar = (id: number) => cars[id]; |
Minusem powyższego rozwiązania jest utrata informacji o kolejności obiektów, co jest zapewnione w przypadku listy. Aby rozwiązać ten problem, możemy w Store trzymać tablicę trzymają odpowiednią kolejność ID:
1 2 3 4 |
export interface State { carOrderIds: string[]; cars: {[id: string]: Car}; } |
Inną opcją jest użycie biblioteki @ngrx/entity, która zadba o kolejność i dostarczy nam pakiet selektorów.
2. Feature modules
Kolejnym poważnym błędem w przypadku aplikacji złożonej z modułów, jest trzymanie wszystkiego w jednym module. Załóżmy, że posiadamy następujące moduły funkcjonalne: Products, Offers, Orders, Admin. W tym przypadku, każdy moduł powinien mieć swój dedykowany Store, a sam NgRx zadba o to, aby wszystko złożyć do kupy. Działa to również bardzo dobrze z Lazy Modules, po załadowaniu modułu, dany FeatureStore doklei się do globalnego Store.
Wykorzystujemy do tego metodę forFeature:
1 2 3 4 5 6 7 8 9 10 |
@NgModule({ imports: [ ... StoreModule.forFeature(ADMIN_MODULE_KEY, reducers), EffectsModule.forFeature([AdminEffects]), ], ... }) export class AdminModule { } |
3. Feature Module, mapa reducerów czy jeden reducer?
Warto wiedzieć, że do FeatureStore możemy przekazać jeden reducerFn, zamiast mapy reducerów. Zredukuje nam to liczbę zagnieżdzeń dla prostych przypadków.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@NgModule({ imports: [ /* gdy Store ma prosta strukturę, przekazujemy po prostu funkcje Reducera: { cars: { // jeden reducer na całość poniżej ordered: [...], items: [...], } } */ StoreModule.forFeature(CARS_FEATURE_MODULE_KEY, carReducerFn), /* gdy moduł funkcjonalny jest bardziej złożony, to możemy przekazać mapę reducerów { cars: {...}, // reducer dla cars oldCars: {...}, // reducer dla oldCars conceptCars: {...}, // reducer dla conceptCars } */ */ StoreModule.forFeature(CARS_FEATURE_MODULE_KEY, carsReducerMap), ], }) export class CarsModule {} |
4. Akcja – payload zawsze jako obiekt.
Dobrą praktyką jest trzymać payload zawsze pod obiektem, nawet jak jest tylko jedna wartość. Poprawia to znacznie czytelność, zwłaszcza w reducerach i efektach, gdzie odnosimy się do payloadu.
ŹLE:
1 2 3 4 5 |
export class StoreCars implements Action { readonly type = CarsActionTypes.STORE_CARS; constructor(public payload: Car[]) {} } |
DOBRZE:
1 2 3 4 5 |
export class StoreCars implements Action { readonly type = CarsActionTypes.STORE_CARS; constructor(public payload: {cars: Car[]}) {} } |
5. combineLatest – wyciągamy wiele wartości ze Store
W NgRx bardzo często dochodzi do sytuacji, kiedy chcemy wyciągnąć ze Store wiele wartości jednocześnie. Z pomocą przychodzi nam Observable Creator – CombineLatest:
1 2 3 4 5 6 7 8 |
import {combineLatest} from 'rxjs'; this.subscription = combineLatest( this.store.select(fromSession.getUser), // poproszę o Usera ze Store this.store.select(fromCars.getCars), // poproszę o Cars ze Store ).subscribe(([user, cars]) => { console.log(user, cars); // wyświetl log, jak wyemituje getCars$ lub getUser$ }); |
Pamiętaj, aby zrobić unsubscribe na subskrypcji, np. w hooku ngOnDestroy oraz, że callback w subscribe uruchomi się za każdym razem, gdy OBOJĘTNIE który ze strumieni wyemituje (getUser lub cars), ale pod warunkiem, że każdy już coś wyemitował.
6. withLatestFrom – wyciągamy wiele wartości ze Store, ale nie nasłuchujemy na nie
Różnica między CombineLatest a withLatestFrom jest taka, że w przypadku CombineLatest, utworzony strumień będzie emitować za każdym razem, gdy którykolwiek z przekazanych strumieni wyemituje, natomiast w przypadku withLatestFrom, chcemy nasłuchiwać tylko na strumień źródłowy – w poniższym przypadku getUser$:
1 2 3 4 5 |
const subscription = this.store.select(fromSession.getUser).pipe( withLatestFrom(this.store.select(fromCars.getCars)), // poproszę przy okazji o Cars ).subscribe(([user, cars]) => { console.log(user, cars); // wyświetl log, jak wyemituje WYŁĄCZNIE getUser$ }); |
WithLatestFrom często wykorzystuje się w efektach:
1 2 3 4 5 6 7 8 9 10 11 12 |
@Effect() saveMethod$ = this.actions$ .ofType(SAVE_METHOD).pipe( switchMap(...) .pipe( withLatestFrom(this.store.select(fromDashboard.getSelectedTab)), // poproszę o zaznaczony tab map(([methodEntity, selectedTab]) => { // zrób co trzeba }), ), ), ); |
WithLatestFrom jest również pomocny, jeśli chcemy wyciągnąć kawałek Store z innego modułu, wystarczy do niego przekazać główny selektor danego modułu.
7. Dispatch: false w efektach
Standardowo, efekt nasłuchuje na daną akcję i zwraca inną:
1 2 3 4 5 6 7 8 9 10 |
@Effect() fetchCars$ = this.actions$.pipe( ofType(FETCH_CARS), // nasłuchuję na FetchCars switchMap(() => this.carsService.getCars() .pipe( map(cars => new StoreCars({ cars })), // zwracam akcję StoreCars catchError(() => of(new FetchCarsFailed())), ) ) ); |
Jeśli efekt nie będzie zwracał żadnej akcji, to zwróci tą, na którą nasłuchuje w OfType. Można popaść przez to w problemy! Gdy nie chcemy w efekcie zwracać akcji – bo np. tylko chcemy zawołać router.navigate, to przekazujemy do dekoratora @Effect obiekt z właściwością dispatch:
1 2 3 4 5 |
@Effect({dispatch: false}) changeTab$ = this.actions$.ofType(CHANGE_TAB) .pipe( tap(() => this.router.navigate(['/admin'])), ); |
8. Wiele akcji w ofType
Widziałem już przypadki duplikacji efektów. Dobrze wiedzieć, że do ofType możemy przekazać wiele akcji na raz.
ŹLE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@Effect() storeValue$ = this.actions$.ofType(SOME_ACTION1) .pipe( map(value => StoreValue(value)), ); @Effect() storeValue2$ = this.actions$.ofType(SOME_ACTION2) .pipe( map(value => StoreValue(value)), ); @Effect() storeValue3$ = this.actions$.ofType(SOME_ACTION3) .pipe( map(value => StoreValue(value)), ); |
DOBRZE:
1 2 3 4 5 |
@Effect() storeValue$ = this.actions$.ofType(SOME_ACTION1, SOME_ACTION2, SOME_ACTION3) .pipe( map(value => StoreValue(value)), ); |
9. Efekt z dowolnego strumienia
Efekt nie musi polegać tylko na akcjach przepływających przez Store. Możemy nasłuchiwać na dowolny Observable:
1 2 3 4 5 6 7 |
@Effect() online$ = merge( fromEvent(window, 'online').pipe(mapTo(true)), fromEvent(window, 'offline').pipe(mapTo(false)), ).pipe( map(online => new ChangeOnlineStatus({online})), ); |
W powyższym przypadku, emituje akcję ChangeOnlineStatus przy każdej zmianie statusu online przeglądarki.
10. Zwrócenie wielu akcji na raz
Jeśli chcesz zwrócić wiele akcji na raz, np. w efekcie, to wykorzystaj concatMap.
1 2 3 4 5 6 7 8 9 10 |
fetchInitialData$ = this.actions$ .ofType(LOGIN_SUCCESS).pipe( concatMap(() => { return [ actions.FetchDictionaries(), actions.FetchApplicationInfo(), actions.FetchApplicationsList(), ]; }), ); |
11. Nie żałuj akcji
Nie rób akcji z flagami. Lepsze są dwie akcje bez payloadu w takich przypadkach. Krócej, nie znaczy lepiej!
ŹLE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export const TOGGLE_MENU = 'Toggle Menu'; export class ToggleMenu implements Action { readonly type = TOGGLE_MENU; constructor(public payload: {shown: boolean}) {} } // reducer case TOGGLE_MENU: return { ...state, menuShown: action.payload.shown }; // dispatch store.dispatch(ToggleMenu(true)); // NIECZYTELNE : ( |
DOBRZE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
export const OPEN_MENU = 'Open Menu'; export const HIDE_MENU = 'Close Menu'; export class OpenMenu implements Action { readonly type = OPEN_MENU; } export class CloseMenu implements Action { readonly type = CLOSE_MENU; } // reducer case OPEN_MENU: return { ...state, menuShown: true }; case CLOSE_MENU: return { ...state, menuShown: false }; // dispatch store.dispatch(OpenMenu()); // CZYTELNE |
12. Operator First()
Od Store musimy się odsubskrybować, z wyjątkiem użycia AsyncPipe w templatce. Bardzo często wystarczy użyć operatora first, aby nie bawić się w unsubscribe.
TAK SOBIE:
1 2 3 4 5 6 7 8 9 10 11 |
private subscription: Subscription; cars: Car[]; ngOnInit() { this.subscription = this.store.pipe(select(fromCars.getCars)) .subscribe(cars => this.cars = cars); } ngOnDestroy() { this.subscription.unsubscribe(); } |
LEPIEJ:
1 2 3 4 |
ngOnInit() { this.store.pipe(select(fromCars.getCars), first()) .subscribe(cars => this.cars = cars); } |
First() skompletuje strumień po wyemitowaniu pierwszej wartości. Tym niemniej uważnie! bo już dalej nie będziesz nasłuchiwał na emitowane wartości tego strumienia.
13. Parametryzowane selektory
Zdarza się, że chcemy wyciągnąć coś ze Store na podstawie parametru. Możemy zrobić to starą szkołą (NgRx < v7.0), czyli napisać funkcję, która zwraca selektor. Niestety, w tym przypadku nie działa memoizacja i dla tego samego parametru, selektor zostanie przeliczony na nowo.
1 2 3 4 5 |
export const getCar = (id: number) => createSelector(getCars, (cars: Car[]) => { return cars.find(car => car.id === id); }); this.store.pipe(select(fromCars.getCar(5))) |
Od wersji NgRx > v7.0.0, mamy dostęp do selektor props:
1 2 3 4 5 |
export const getCar = createSelector(getCars, (cars: Car[], props: {id: number}) => { return cars.find(car => car.id === props.id); }); this.store.pipe(select(fromCars.getCar, {id: 5})); |
Ogólne wskazówki:
– korzystaj z NgRx Schematics, aby szybko setupować Store
– zastanów się zawsze 3 razy, czy coś na pewno powinno być w Store, być może wystarczy lokalny stan komponentu, np. dla stanu formularza
– zbyt wiele komponentów świadomych Store -> nie dopuść do tego, rozważ zawsze czy na pewno chcesz wstrzyknąć Store do danego komponentu (może rodzic ma już Store i może poprzez @Input przekazać wartość do dziecka?)
– zawsze typuj cały State i payload akcji, nie pozwól sobie na żadne “any”, szybko stracisz kontrolę nad tym co wpada i wychodzi ze Store
– zawsze rób unsubscribe na selektorach, chyba, że użyłeś np. operatora first() lub nasłuchujesz poprzez AsyncPipe.
– reducery trzymaj możliwie proste, ich logika powinna być uboga
– wykorzystuj selektory z logiką, aby łączyć dane ze Store. Nie duplikuj parę razy CombineLatest w wielu miejscach, lepiej stwórz selektor pod to korzystający z dwóch innych selektorów
– staraj się trzymać płaski State, im mniej zagnieżdzeń tym lepiej
– nie dubluj tych samych wartości w Store. Zamiast trzymać w Store zaznaczone obiekty w tabeli, trzymaj wyłącznie ich IDs. Nie będziesz musiał się martwić o akutalizację danych we wszystkich miejscach w przypadku zmian.
– zawsze testuj główne składowe -> selektory, reducery, efekty oraz Store w komponentach, czy. np dispatchuje akcje na click.
– użyj Redux DevTools do debugowania i time travelingu
– użyj biblioteki ngrx-store-freeze, aby mieć pewność, że nie mutujesz nigdzie stanu
Podsumowanie
NgRx jest potężnym narzędziem, ale jednocześnie dość skomplikowanym, z dużym boilerplate i narzutem wiedzy w postaci RxJS. Jest dużo miejsc, gdzie można coś spieprzyć. Moim zdaniem, nadaje się dobrze do dużych, złożonych aplikacji, ale do czegoś mniejszego, protszego, wybrałbym MobX lub NGXS.
Być może masz pytania z NgRx? Jeśli tak, to pytaj śmiało w komentarzach 🙂
Świetny artykuł, który w szczególności pozwoli odnaleźć się “nowym” w ngrxie. 🙂 Jednak nie moge się zgodzić z punktem 10 z dwóch powodów.
1. W całym ekosystemie ngrx powinniśmy traktować akcję jako unikalne zdarzenie i nie emitować wielu akcji w tym samym czasie z jednego miejsca. Pozwala na mniej bolesne debugowanie poszczególnych stanów aplikacji oraz zachowanie lepszej higieny naszego store’a. Emitowanie kilku akcji niestety powoduje też produkcję kolejnych stanów aplikacji. Dodatkowo może dochodzić do zachowań typu race condition, na które sam jak zaczynałem też się kiedyś naciąłem.
2. Używanie efektu wyłącznie po to by wysłać kolejne akcje jest złym wykorzystaniem efektów, które przeznaczone są przede wszystkim do pracy z side effectami, na które nie mamy wpływu, tzn. nie mamy pojęcia kiedy pojawi się się z niego odpowiedź. Tą kwestię jak i parę innych odnośnie używania efektów świetnie porusza ten artykuł: https://medium.com/@m3po22/stop-using-ngrx-effects-for-that-a6ccfe186399.
Zapraszam do dyskusji ala “Change My Mind”, bo dzielenie się dobrą wiedzą też jest również dobrą praktyką. 🙂
Pozdrawiam 😀
A ja pozwolę się nie zgodzić z Konrad Klimczak.
Jeżeli swoją opinię budujesz na podstawie https://www.youtube.com/watch?v=JmnsEvoy-gY to nie polecam.
Zauważ, że komponenty powinny emitować akcję jako sygnał, że wydarzył się jakiś event (event action to jeden z 3 rodzajów akcji, pozostałe to command i document). Komponentu nie interesuje czy w związku z tym, że zainicjalizował się ma nastąpić pobranie danych czy też lot w kosmos. Jest to luźne powiązanie i dzięki temu zawsze możesz stworzyć dodatkowy efekt który nasłuchuje na tą akcję bądź w istniejącym efekcie zwrócić dodatkową akcję.
W poprzedniej pracy stworzyliśmy dwie duże aplikacje bazujące na tym flow i nowe osoby w projekcie bardzo chwaliły sobie czytelność kodu.
Pozdrawiam
Zgodzę się z Tobą, że po komponent ma wyłącznie poinformować o utworzeniu siebie i co za tym idzie wywołać efekt, ale bardziej mi chodzi o niepotrzebne komplikowanie łańcucha wywołań do pobrania danych, bo w twoim przypadku mamy:
LoginSuccess => LoadUsers => LoadUsers{Success|Failure}
a dlaczego nie uprościć do postaci:
LoginSuccess => LoadUsers{Success|Failure}
Nie widzę sensu tworzenia akcji tylko po to by została następnie przekonwertowana przez kolejny efekt na akcję wynikową. U mnie w projekcie osoby narzekały, że muszą robić dodatkową akcję (tj. action producer), która nic nie wnosi do stanu aplikacji, bo jest od razu łapana przez kolejny efekt. Po zmianie podejścia mamy zdecydowanie mniej kodu, który nie traci na czytelności, a zyskujemy na mniejszej ilości akcji, a co za tym idzie, dużo łatwiejsze odnalezienie się w DevToolsach.
Jakiś kurs NgRx planujesz na stronie?
Też się nie zgadzam z Konradem. Side effects często mi służyły do rozdzielania/agregowania akcji. Komponent wysyła tylko event/command którym informuje o zdarzeniu/czego wymaga. U mnie w projekcie własnie side effect potrafił wyrzucać akcje żeby rozbijać to na kolejne, ponieważ komponent nie powinien sterować.
https://blog.nrwl.io/ngrx-patterns-and-techniques-f46126e2b1e5
Tutaj bardzo fajnie są opisane design patterny z użyciem side Effects. Jest tam wąłśnie mowa agregowaniu, rozdzielaniu i decydowaniu na bazie akcji oraz contentu
przykłąd 10 idealnie pokazuje splittera. Pobierz dodatkowe rzeczy, które są reakcją na akcje
A jak proponujecie np zamkniecie modala club inna akcje w komponecie po zakonczeniu effectu. Przeslanie referencji w akcji I wywolnie w tap, nasluchiwanie na effect w komponecie (nie jest to zbytnie zwiazanie komponentu w ngrx framework). Dodatkowo interesuje mine wasza opinie zeby ngrxowe selecty i dispatch akcji umiescic w dodatkowej warstwie abstrakcji, someStateService,. I poprzez DI inicjalizowac observable properties w kontenerze a później przez async pipe do komponentu. Mam nadzieję że napisałem to w miarę jasno
Jeżeli chodzi o obsługę modali to zależy od podejścia.
Fajnie wygląda to w materialu, bo dialog ma referencję do siebie. Na close, zamyka sam siebie oraz leci observable z resultem, więc w side effect otwierasz modala i obsługujesz observable po zamknięciu:
@Effect()
openDialog = this.actions.pipe(
ofType(LoginActionTypes.OpenLoginDialog),
exhaustMap(_ => {
let dialogRef = this.dialog.open(LoginDialog);
return dialogRef.afterClosed();
}),
map((result: any) => {
if (result === undefined) {
return new CloseDialog();
}
return new LoginDialogSuccess(result);
}),
);
W PrimeNg mieli inne podejście (nie wiem jak teraz). Dialogi były renderowane w kontenerze, i wtedy był ngIf na wartość ze store, czy dany dialog powinien byc renderowany. Side effect nic nie robił, akcje na open, close leciały z konenera renderującego dany dialog i nasłuchującego stan
Co do drugiej części z someStateService, to klasyczny przykład fasady dla store.
https://medium.com/@thomasburlesonIA/ngrx-facades-better-state-management-82a04b9a1e39
Jest to fajne, do momentu jak jest tylko fasadą (brak logiki biznesowej i zastępowania selektorów). Łatwiej się mockuje w przypadku unit testów niż cały store.
Czy w aplikacjach e-commerce często stosuje się ngrx-store? Jak to wpływa na szybkość i performance strony?