27 lut 2019
5 min

NgRx praktycznie – garść wskazówek

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.

Store module diagram

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 🙂

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.