Zakładam, że słyszeliście już o State Managemencie – do czego służy, dlaczego powinniśmy go stosować i w czym może nam pomóc. Jak chcecie odświeżyć swoją wiedzę, to polecam zajrzeć do nagrania prelekcji Mateusza Do Duca z Angular Meetup.
My, w ramach tego artykułu zajmiemy się NgRx – przejrzymy kilka nowości i sposobów na ułatwienie sobie z nim pracy.
Artykuł jest aktualizacją starszego artykułu o NgRx: https://www.angular.love/2019/02/27/ngrx-praktycznie-garsc-wskazowek/
Akcje
Każdy, kto korzystał kiedyś ze state managementu od NgRxa, na pewno dobrze wie czym są i do czego służą akcje. Zasada działania pozostaje taka sama pomiędzy wersjami, więc co w takim razie zmieniło się w samych akcjach? Przede wszystkim składnia, jak w całym NgRxie 🙂 W tym momencie mamy dwie drogi do tworzenia akcji.
Pierwsza to używanie metody createAction:
1 2 3 4 |
export const login = createAction( '[Login Page] Login' props<{ payload: LoginPayload }>() ); |
W porównaniu z poprzednim sposobem, czyli klasa z konstruktorem, jest prościej.
1 2 3 4 5 |
export class Login implements Action { readonly type = '[Login Page] Login' constructor(public payload: LoginPayload){} } |
Mniej boilerplate’u i wygląda dużo ładniej, ale czy jest to sposób doskonały? Nie. Pewnie nie raz mieliście sytuacje kiedy metodą copiego pasta tworzyliście nowe akcje i zapomnieliście zmienić typ akcji. Typy muszą być unikalne. W przeciwnym razie, może to spowodować nieoczekiwany rezultat, na przykład podwójny call do API. Tutaj z pomocą przychodzi nam zupełnie nowy sposób createActionGroup:
1 2 3 4 5 6 7 8 |
const authApiActions = createActionGroup({ source: 'Auth API', events: { 'Login': props<{ payload: LoginPayload }>(), 'Login Success': props<{ userId: number; token: string; }>(), 'Login Failure': props<{ error: string; }>(), }, }); |
Sposób, który pozbawia nas problemów – events jest recordem, nasz typ jest kluczem i nie możemy dać dwóch akcji z takim samym kluczem. Cudo.
concatLatestFrom
Do tej pory, jeśli chcieliśmy w efekcie wyciągnąć dane ze state’u, zawsze korzystaliśmy z operatora withLatestFrom. W tej chwili team NgRxa zaleca używane nowego – concatLatestFrom. Jaka jest pomiędzy nimi różnica?
Przy operacji wyciągania danych ze state’u czasami mogliśmy się spotkać z sytuacją kiedy otrzymujemy nieaktualne dane. Wtedy przychodzi nam na myśl, że coś musi dziać się za szybko. W naszym przypadku za szybkie jest właśnie wyciąganie danych ze statu.
W withLatestFrom subskrybcja była “eager”, czyli mogła nasłuchiwać na akcję jeszcze zanim ona została wykonana w efekcie. W nowym operatorze concatLatestFrom subskrybcja jest “lazy”, to znaczy, że akcja zacznie być nasłuchiwana dopiero wtedy gdy wykona się w efekcie. ConcatLatestFrom eliminuje ten błąd i pobiera dane dopiero po zdispatchowaniu akcji.
Entities
Dobrą praktyką w zarządzaniu danymi jest przechowywanie jako obiekty z kluczem, czyli mapa. Świat stał się piękniejszy kiedy team NgRxa stworzył rozwiązanie, które nam to zapewnia out of the box. Wystarczy tylko użyć @ngrx/entitiy. Używanie @ngrx/entitiy do przechowywania danych daje nam również benefit w postaci szybkości odczytu danych. Złożoność obliczniowa operacji pobrania danych z mapa wynosie O(1) w porówaniu do O(n) w przypadku tablicy.
State tworzony za pomocą entity wygląda tak:
1 2 3 4 5 6 7 8 9 10 11 |
export interface State extends EntityState<User> { selectedUserId: string | null; } export const adapter: EntityAdapter<User> = createEntityAdapter<User>({ selectId: (user: User) => user.userId }); export const initialState: State = adapter.getInitialState({ selectedUserId: null, }) |
Nasz interface state’u rozszerzamy przez EntityState, a nasz inicjalny stan tworzymy za pomocą entityAdaptera.
Rezultatem jest stworzony state, który oprócz zadeklarowanych przez nas pól, ma dodatkowe pola: entites i ids. Entities jest mapą obiektów, które chcemy w nim trzymać. Domyślnie kluczem jest props id. Możemy oczywiście go zmienić. W tym przypadku selectUserId będzie naszym kluczem. Dlaczego wspominam o domyślnym kluczu i możliwości jego zaminy? Przyjmijmy, że nasz model usera wygląda tak:
1 2 3 4 5 |
export interface User { userId: number; name: string; surname: string; } |
W takim przypadku, jeśli ustawimy dane tak jak przychodzą z API do naszego statu zobaczymy, że dane nie ustawiły sie prawidłowo.
W entites pierwszy klucz jest undefined właśnie dlatego, że nie ustawiliśmy innego klucza dla mapa. W takim wypadku musimy albo przemapowac nasze dane i dodać do nich dodatkowy props (id) lub zmienić domyślmy klucz entites z id w naszym przypadku na userId.
Czym tak właściwie jest ten adapter? Adapter udostępnia nam metody do modyfikacji entities. Czyli za jego pomocą możemy coś do entities dodać, usunąć, zmodyfikować i wiele innych.
1 2 3 4 5 6 7 8 9 10 11 12 |
export const userReducer = createReducer( initialState, on(UserActions.loadUsers, (state, { users }) => { return adapter.setAll(users, state); }), on(UserActions.updateUser, (state, { update }) => { return adapter.updateOne(update, state); }), on(UserActions.deleteUser, (state, { id }) => { return adapter.removeOne(id, state); }), ); |
Selektory
Selektory z props są w tym momencie deprecated. Prosty przykład jak wyglądał selektor z propsem, a jak powinien wyglądać w tym momencie.
1 2 3 4 5 |
// correct export const getCount = (multiply: number) => createSelector( getCounterValue, (counter) => counter * multiply ); // depricated export const getCount = createSelector( getCounterValue, (counter, props) => counter * props.multiply ); |
Zmienił się też zalecany sposób wywoływania selektorów. Teraz wygląda to dużo bardziej przyjaźnie.
1 2 3 4 5 |
// use that this.store.select(selectUser()); // instead of this.store.pipe(select(selectUser)); |
NgRx a standalone components
Wersja 14 angulara przyniosła sporo zmian, w tym długo wyczekiwane podejście standalone. W tym momencie możemy stworzyć naszą aplikację bez użycia modułów. Jak w takim razie zadeklarować reducer i effecty?
Jeśli chcemy zadeklarować nasz state lub efekt na globalnym poziomie możemy to zrobić za pomocą metody importProviderFrom w application injectorze.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
bootstrapApplication(AppComponent, { providers: [ importProvidersFrom( StoreModule.forRoot({ router: routerReducer, auth: authReducer, }), StoreRouterConnectingModule.forRoot(), StoreDevtoolsModule.instrument(), EffectsModule.forRoot([RouterEffects, AuthEffects]) ), ], }); |
Ngrx przygotował również swoje dedykowane metody do importowania state i effectów: provideEffect i provideStore.
1 2 3 4 5 6 7 8 |
bootstrapApplication(AppComponent, { providers: [ provideStore({ router: routerReducer, auth: AuthReducer }), provideRouterStore(), provideStoreDevtools(), provideEffects([RouterEffects, AuthEffects]), ]), }); |
Zamiennik importu forFeature jest równie prosty. State i efekty możemy provide’ować w routingu.
1 2 3 4 5 6 7 8 |
path: '', providers: [ provideStoreFeature('users', usersReducer), provideFeatureEffects([UsersApiEffects]), ], children: [ ... ] |
Podsumowując, na przestrzeni lat w NgRx’ie dochodzi całkiem sporo nowych rozwiązań, usprawnień które ułatwiają pracę nam – deweloperom. Nie ma wątpliwości, że NgRx jest najbardziej popularnym i najczęściej spotykanym state managementem w projektach angularowych więc warto być na bieżąco, zawsze aktualizować paczki i zaglądać na naszego bloga, gdzie regularnie informujemy was o tipach i nowościach, które są wydawane.
Świetny artykuł. Czy definicja adaptera nie powinna wyglądać tak?:
export const adapter: EntityAdapter = createEntityAdapter({
selectId: (user: User) => user.userId
});
Piotr, dzięki za feedback 😀 Jeśli chodzi o adapter, jasne 🙂 masz rację, powinna być tam przekazana funkcja 🙂