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:
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)):
getCar = (id: number) => cars.find(car => car.id === id);
DOBRZE:
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)):
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:
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:
@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.
@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:
export class StoreCars implements Action {
readonly type = CarsActionTypes.STORE_CARS;
constructor(public payload: Car[]) {}
}
DOBRZE:
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:
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$:
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:
@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ą:
@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:
@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:
@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:
@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:
@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.
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:
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:
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:
private subscription: Subscription;
cars: Car[];
ngOnInit() {
this.subscription = this.store.pipe(select(fromCars.getCars))
.subscribe(cars => this.cars = cars);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
LEPIEJ:
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.
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:
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 🙂