Zarządzanie stanem aplikacji frontendowej za pomocą NgRx

Paweł Adamowicz | Rozwój oprogramowania | 06.10.2022

NGRX

Budowane dziś aplikacje webowe są coraz bardziej rozbudowane i dynamiczne oraz przetwarzają sporo danych. W takiej sytuacji wyzwaniem staje się zarządzanie stanem aplikacji. Sprawdź, kiedy warto zdecydować się na rozwiązanie do kontroli stanu aplikacji i które wybrać. W artykule omawiam możliwości NgRx oraz zasadę działania architektury Redux. Przedstawiam też alternatywy, takie jak Akita, NGXS oraz RxJS. Zapraszam do lektury!

Czy potrzebuję NgRx do kontroli stanu aplikacji?

Obecne aplikacje webowe mają coraz mniej wspólnego z aplikacjami, które widzieliśmy jeszcze paręnaście lat temu. Szybkość rozwoju wszelkiego rodzaju bibliotek, frameworków, jak i samego podejścia do pisania frontendu aplikacji uległy znacznej zmianie.

Aplikacje webowe kiedyś

Dawniej strony internetowe tworzono w formie statycznej, gdzie treść strony nie była generowana po stronie serwera czy backendu. Były one statyczne i rzadko aktualizowane. Rozwiązanie to było bardzo szybkie w implementacji, ale i bardzo mało elastyczne, nie nadawało się ono również do tworzenia dynamicznych aplikacji.

Oczywiście można było dodawać różne skrypty do takiej strony internetowej, aby stworzyć iluzję dynamiki, ale nie było to rozwiązanie rozbudowane w takim stopniu, w jakim widzimy to dzisiaj.

Z biegiem czasu zaczęto więc stosować rozwiązanie o nazwie MPA (Multi Page Appliaction). W wielkim skrócie chodzi o to, że po stronie backendu generowała się dynamiczna treść HTML, a niektóre interakcje użytkownika, takie jak przejście na kolejną podstronę, powodowały kolejne wysłanie zapytania do serwera z prośbą o wygenerowanie nowego widoku i jego wyświetlenie po stronie przeglądarki.

Rozwiązanie takie powodowało wiele zapytań do serwera, co było dosyć uciążliwe w tamtych czasach, jednak nawet dzisiaj spora część portali e-commerce, blogów oraz forów internetowych działa dzięki temu właśnie rozwiązaniu.

frameworki frontendowe

Najlepsze frameworki frontendowe: React, Angular czy Vue.js?

Przeczytaj artykuł

Aplikacje webowe dziś

Potrzeby użytkowników, jak i trendy się zmieniają. Na chwilę obecną spora część aplikacji jest tworzona w technologii SPA (Single Page Application). Zaletą stosowania tego rozwiązania jest przede wszystkim to, że aplikacja nie przeładowuje się za każdym razem, kiedy użytkownik wejdzie z nią w interakcję.

Jest to rozwiązanie bardziej efektywne, dużo szybsze i na pewno mniej obciążające dla serwera. Od momentu pojawienia się SPA zaczęły się pojawiać coraz bardziej rozbudowane i skomplikowane aplikacje internetowe, ponieważ cała treść mogła być generowana już po stronie frontendu całkowicie dynamicznie, a dane z serwera były pobierane bez przeładowania za pomocą np. REST API.

Możliwości i wyzwania współczesnych aplikacji webowych

Kiedy aplikacje internetowe zaczęły coraz bardziej przypominać aplikacje, które instalujemy na komputerze czy telefonie, pojawiły się też pewne nowe możliwości, ale i przeszkody.

Z uwagi na to, że to już nie serwer był odpowiedzialny za generowanie plików HTML z nową treścią, a sama przeglądarka z użyciem JavaScript generowała dynamicznie treść, w pewnym momencie danych było tak wiele, że trzeba było je gdzieś przechowywać.

O ile jeszcze przechowywanie stanu w mniejszych, a nawet średnich aplikacjach nie jest jakimś dużym problemem, o tyle w aplikacjach dużo większych, nawet gdy mamy bardzo dobrą architekturę, w pewnym momencie jest nie lada wyzwaniem. Często bez dobrego rozwiązania do przechowywania stanu możemy skończyć na refaktorze sporej części kodu.

Przeczytaj także:

Refaktoryzacja kodu PHP

Małe aplikacje

Dla tych najmniejszych aplikacji prawdopodobnie możemy pokusić się o użycie mniejszej biblioteki lub nawet samego JavaScriptu do przechowywania stanu, bo nie ma tam przeważnie za dużo obliczeń, interakcji czy też zapytań do API. Z tego względu czasami nie ma sensu zaprzęgać większego frameworka, biblioteki czy też innego narzędzia do przechowywania aktualnego stanu aplikacji.

Bardziej rozbudowane aplikacje

Przy aplikacjach większych, tak jak wspomniałem wcześniej, oczywiście stan aplikacji da się przechowywać jeszcze w różnego rodzaju dedykowanych serwisach po stronie frontendu, ale z biegiem czasu powiązań w kodzie jest coraz więcej, staje się on coraz bardziej skomplikowany, a co za tym idzie – po prostu trudny w utrzymaniu. Czasami – nawet nieprzewidywalny w swoim działaniu.

W momencie, kiedy aplikacja osiągnęła spore rozmiary, może dojść do sytuacji, w której mając dany stan aplikacji w kilku miejscach, tak naprawdę nie mamy jednego źródła prawdy, czyli miejsca, gdzie dla danego zapytania otrzymamy zawsze ten sam wynik, a nie dwa całkowicie inne.

Kiedy zdecydować się na rozwiązania do zarządzania stanem aplikacji?

Wyobraźmy sobie sytuację, w której aplikacja sklepu internetowego w przeglądarce renderuje nam dwie różne ceny takiego samego produktu, chociaż powinny one być identyczne. Albo sytuację, gdy użytkownik został wylogowany, a mimo to na stronie wyświetlają mu się jeszcze informacje użytkownika.

Wbrew pozorom, kiedy nie polegamy na jednym źródle prawdy, takich sytuacji jest wiele, i kiedy do tego dochodzi, warto zastanowić się: może już czas, by zastosować biblioteki do zarządzania stanem aplikacji?

Taka decyzja powinna zapaść możliwie jak najwcześniej podczas tworzenia aplikacji internetowej, bo przeważnie na późniejszym etapie jest to często niemożliwe albo wiąże się z przerabianiem sporej części kodu i dostosowywaniem starej części aplikacji, co jest niestety bardzo kosztowne.

Czym jest NgRx?

Czym w takim razie jest NgRx i jak nam może pomóc?

NgRx jest to zbiór bibliotek, które służą do budowania reaktywnych aplikacji z wykorzystaniem Angulara. Dzięki niemu możemy zarządzać stanem z wykorzystaniem między innymi wyizolowanych efektów ubocznych. Mamy jedno źródło prawdy, a więc już nigdy nie będziemy musieli zastanawiać się czy np. tak jak w przykładzie powyżej – cena danego produktu jest prawidłowa.

Kiedy stosować NgRx?

NgRx do kontroli stanu aplikacji nadaje się idealnie w przypadkach, kiedy w warstwie UI sporo się dzieje. Przykładowo:

  • na stronie jest dużo formularzy z opcją zapamiętywania danych
  • potrzebujemy zapisywać gdzieś stan wypełnionego formularza, aby do niego później wrócić
  • lub też po prostu zależy nam na zapisywaniu konkretnego stanu aplikacji bezpośrednio po interakcjach użytkownika

Co warto wiedzieć, zanim zdecydujemy się na NgRx?

Należy pamiętać, że próg wejścia jest dosyć wysoki, ponieważ oprócz samej znajomości biblioteki i jej architektury trzeba bardzo ściśle przestrzegać pewnych konwencji pisania kodu, znać RxJS na dosyć wysokim poziomie. W ostatecznym rozrachunku jednak korzyści ze stosowania NgRx przeważą. Nakład pracy też nie jest bez znaczenia, bo o ile przy pisaniu zwykłych, niezbędnych dla aplikacji serwisów napiszemy kod bardzo szybko, o tyle przy używaniu NgRx będzie to trwało kilka razy dłużej z uwagi na jego architekturę. A przecież dodatkowo musimy napisać jeszcze testy jednostkowe.

Wydaje się to dosyć skomplikowane na początek, prawda? Zobaczmy więc, jak wygląda taka architektura od środka.

Architektura Redux

Architektura Redux cechuje się tym, że wszystko, co dzieje się w aplikacji, jest bardzo przewidywalne. Dzieje się tak dlatego, że mamy tam jednokierunkowy przepływ danych, który wymusza na nas pewne konwencje oraz ścisłe zdefiniowanie, kiedy ma zostać zmieniony stan aplikacji. Dzięki temu chroni ona aplikację przed niepożądanymi zmianami stanu.

Jedną z głównych zasad Reduxa jest posiadanie jednego globalnego stanu aplikacji, który jest niemutowalny i przechowywany w obiekcie store, zwanym też czasami single source of truth (SSOT).

Zmiany na stanie store mogą zadziać się tylko i wyłącznie za pomocą dispatchowania akcji do naszego store i nigdy nie powinniśmy tego robić bezpośrednio.

Jak to działa?

Zaczynając od samej warstwy widoku, podczas interakcji użytkownika zostaje wywołana metoda o nazwie dispatch, do której przekazywany jest w parametrach obiekt akcji. Akcje są to często zwykłe obiekty, posiadające swój typ oraz opcjonalnie payload, czyli dodatkowe informacje, które możemy przekazać razem z akcją.

Z reguły każda akcja, a właściwie jej nazwa powinna być unikalna, ponieważ pozwala to dowiedzieć się, jaka interakcja w danym momencie zaszła lub jaki event miał miejsce w danym czasie.

Po wywołaniu metody dispatch z akcją, store zostaje poinformowany, że właśnie została zainicjalizowana dana akcja. Następnie trafia ona do reducera wraz z aktualnym stanem aplikacji.

Reducer w tym momencie, na podstawie wysłanej akcji, otrzymuje ją wraz z aktualnym stanem, a następnie zwraca nam kopię nowego stanu. Reducery są to czyste funkcje (pure functions) – nie powinny mieć w sobie żadnych efektów ubocznych (side effects), czyli np. komunikacji z API, a dla danego inputa zwrócą nam zawsze taki sam output.

Po zwróceniu nowej kopii stanu nasz obiekt stanu zostaje zastąpiony nową kopią, a wszystkie zainteresowane tym obserwatory (observers) zostają poinformowane o zmianie, co skutkuje np. aktualizacją interesujących nas danych po stronie UI.

Inne aspekty w NgRx

Oczywiście opisany wyżej przykład jest typowym flow w NgRx i nie uwzględnia takich aspektów jak zapytania do API, różnego rodzaju loggery czy inne ciekawostki, ale i tutaj mamy wprowadzoną dla nas warstwę middleware, z której możemy skorzystać – są to efekty uboczne, czyli side effects.

Efekty uboczne – side effects

Side effects w uproszczeniu działają w ten sposób, że za każdym razem, kiedy wywołujemy metodę dispatch z daną akcją, powinna ona przejść przez wszystkie warstwy middleware razem ze stanem.

Efekt uboczny na podstawie typu akcji decyduje, czy zwróci nową akcję asynchronicznie, czy też nie.

Jednym z przykładów użycia side effect jest wyżej wspominana komunikacja z API. Do efektu trafia dana akcja, a nastepnie preważnie z użyciem dedykowanego serwisu komunikujemy się z naszym REST API, aby następnie zwrócić akcję.

Niepisaną zasadą jest, że przeważnie w tej sytuacji zwracamy akcję typu success lub failed, w zależności od tego, czy z serwera przyszła odpowiedź prawidłowa, czy też błędna. Na podstawie typu danej akcji możemy obsłużyć ją po stronie UI.

side effects

Store

Store, jak wskazuje nazwa, odpowiada za przechowywanie stanu naszej aplikacji. To miejsce, gdzie łączą się wszystkie części architektury Redux.

Sam store może mieć zadeklarowane początkowo dane, które później zmieniają się wraz z interakcją użytkownika w aplikacji za pomocą specjalnych funkcji – reducerów. Obiekt stanu nie musi być monolitem; możemy posiadać kilka podstanów dla każdej interesującej nas funkcjonalności lub modułu aplikacji.

Dla przykładu, w store możemy przechowywać takie informacje jak dane zalogowanego użytkownika, wybrane produkty w koszyku w sklepie czy nawet osobno wypełnione dane w formularzu do wysyłki produktu, który przed chwilą wypełnialiśmy, ale z jakiegoś powodu wróciliśmy na poprzednią stronę.

Akcja

Akcje są to klasy implementujące interfejs Action. Reprezentują one unikalne eventy, które występują w aplikacji podczas różnego rodzaju interakcji użytkownika z UI, jak również takie, których nie widzimy np. w efektach. Akcja posiada swój określony typ, często też posiada payload, czyli dane, które przekazujemy wraz z akcją.

Akcje pozwalają nam zrozumieć cały flow w aplikacji, są bardzo przewidywalne, i dzięki specjalnym narzędziom do debugowania – możemy szybko prześledzić ich działanie i wychwycić ewentualny błąd w aplikacji.

Reducer

Reducery to czyste funkcje przyjmujące dwa parametry – aktualny stan aplikacji oraz akcję, którą przekazaliśmy. Funkcje te odpowiadają za stan aplikacji i tylko z ich pomocą powinniśmy zmieniać stan aplikacji, nigdy bezpośrednio.

Reducer sprawdza typ akcji i na podstawie zaimplementowanej logiki w funkcji decyduje, co zrobić ze stanem. Dla przykładu, kiedy chcemy zaktualizować dane użytkownika, możemy wywołać metodę dispatch z danym payloadem o przykładowej nazwie UPDATE_USER. W momencie gdy akcja trafi do reducera, sprawdzi on jej typ, a następnie na podstawie typu znajdzie odpowiednią część kodu do wykonania i zwróci nową kopię stanu wraz z uzupełnionymi danymi użytkownika.

Efekt

Efekty dzięki zasileniu przez RxJS dają nam możliwość tworzenia side effects. Efekty stanowią dedykowane miejsca, w których możemy oddelegować różne czynności, takie jak pobieranie danych z REST API, a o których tak naprawdę komponenty nie powinny wiedzieć.

Efekty izolują side effects od komponentów, pozwalają na zachowanie w nich bardzo czystego kodu.

Dla przykładu, kiedy wywołamy metodę dispatch z akcją GET_USER, akcja zostanie wychwycona po swoim typie i już wewnątrz efektu następuje komunikacja z REST API za pomocą serwisu.

Kiedy dane z REST API zostaną pobrane, efekt zwróci nam nową akcję, może to być np. akcja typu GET_USER_SUCCESS lub GET_USER_FAILED. Następnie na podstawie tych akcji możemy np. wyświetlić odpowiedni komunikat dla użytkownika o błędzie lub obsłużyć ją w inny dowolny sposób po stronie aplikacji.

Selektor

Selektory to pure functions, których używamy w momencie, kiedy chcemy pobrać jakieś dane ze store. Pure function oznacza, że zawsze dla tych samych argumentów zostanie zwrócony ten sam wynik.

Selektorów możemy używać w komponentach, aby nasłuchiwać na zmiany w store – wystarczy je raz zasubskrybować. Podczas zmiany stanu obserwator zostanie poinformowany o tym fakcie, a co za tym idzie – UI zostanie zaktualizowany. To też oczywiście zależy od naszej implementacji.

Stan aplikacji może być jednym wielkim płaskim obiektem, ale może być też podzielony na mniejsze części, np. takie jak panel użytkownika. Dla przykładu, chcąc pobrać imię i nazwisko danego użytkownika ze store, wystarczy utworzyć selektor, który pobierze tylko taki wycinek, który nas interesuje, a nie całość stanu aplikacji.

W praktyce wygląda to tak, że w aplikacji mamy bardzo dużo selektorów. Jedne odpowiadają za pobranie danych produktu, drugie pobierają dane użytkownika, a jeszcze inne np. listę produktów w wyszukiwarce.

Alternatywa dla NgRx

Jak widać, architektura Redux posiada elementy, które możemy wykorzystywać odpowiednio do własnych potrzeb, ale nie należy ona do najprostszych. Nakład pracy do stworzenia dobrze działającej aplikacji również jest niemały.

Obecnie jest dostępnych kilka całkiem ciekawych alternatyw dla NgRx. Poniżej postaram się przedstawić kilka z nich.

Akita

Akita to rozwiązanie do zarządzania stanem, zainspirowane koncepcjami z Fluxa i Reduxa. Cechują ją prostota oraz szybkość implementacji w aplikacji.

Akita ma dużo niższy próg wejścia niż NgRx, więc nawet mniej doświadczeni developerzy będą sobie w stanie poradzić. Dodatkowo posiada dosyć obszerny zestaw narzędzi, które przydadzą się, aby szybko wystartować z aplikacją.

Kolejną zaletą Akity jest to, że nie jest ściśle powiązana z Angularem, a co za tym idzie – możemy jej użyć przy projektach które korzystają z Reacta, Vue.js, Svelte czy nawet zwykłego JS. Nie potrzebujemy tutaj nic więcej – wystarczy jej użyć; kod do tego potrzebny jest zredukowany do minimum.

Należy też wspomnieć o tym, że Akita posiada bardzo obszerną dokumentację. Autorzy naprawdę się postarali. W dokumentacji znajdziemy wszystkie potrzebne informacje na start, a wielkość społeczności, która korzysta z tego rozwiązania, świadczy o tym, że coraz bardziej zyskuje ono na popularności.

Architektura Akity

Jeśli chodzi o architekturę Akity, jest ona podobna do tej, którą proponuje NgRx, tylko trochę uszczuplona o niektóre elementy. Kluczowe tutaj jest posiadanie stanu jako jednego obiektu będącego jedynym źródłem prawdy.

Zmiana stanu może zadziać się tylko i wyłącznie za pomocą wywołania metody setState lub jednej z metod aktualizacji bazującej na tej metodzie. W NgRx posiadaliśmy podobną metodę o nazwie dispatch.

Kolejną ważną zasadą jest to, że komponent nie może pobierać danych bezpośrednio ze store. Powinno dziać się to za pomocą zapytań (query), czyli bardzo podobnie jak w NgRx, gdzie są selektory.

Logika oraz wywołania aktualizacji powinny być całkowicie oddzielone od komponentów i zamknięte w dedykowanych serwisach.

Akita

NGXS

Jest to kolejna alternatywa dla NgRx, pozwalająca na zarządzanie stanem w aplikacjach angularowych. NGXS zachowuje zasadę jedynego źródła prawdy, jest bardzo łatwy w implementacji, kod potrzebny do jego wdrożenia redukuje do absolutnego minimum.

W porównaniu do NgRx, gdzie musieliśmy tworzyć różnego rodzaju stany, akcje, reducery i efekty, w tym przypadku to wszystko praktycznie sprowadza się do utworzenia obiektu stanu i odpowiednich akcji, a z resztą NGXS sobie poradzi.

Tak jak już wcześniej wspomniałem, jest to rozwiązanie dużo mniej skomplikowane niż te, które proponują NgRx czy nawet Akita. Znacznie redukuje kod, co sprawia, że wszystko jest tak proste, jak to tylko możliwe. Co więcej, nie musimy być też obeznani z biblioteką RxJS, co jest dla mnie osobiście dużym plusem. Rekomendowałbym to rozwiązanie osobom, które dopiero zaczynają swoją przygodę z rozwiązaniami do zarządzania stanem.

Architektura NGXS

NGXS bazuje na wzorcu CQRS, który znamy z implementacji w bibliotekach takich jak Redux i NgRx. Architektura składa się właściwie z czterech głównych komponentów: store, akcja, stan i selektor. Podobnie jak w NgRx zapewniają one jednokierunkowy przepływ danych z komponentu do store za pomocą akcji, a pobieranie danych (podobnie zresztą jak w NgRx) jest możliwe za pomocą selektorów.

  • Store – w NGXS jest globalnym managerem stanu, który oddelegowuje akcje do odpowiednich kontenerów oraz pozwala nam pobierać z nich „wycinki” danych z globalnego stanu aplikacji.
  • Akcje – unikalne eventy, które mają miejsce w odpowiedzi na różnego rodzaju interakcje użytkownika (np. z warstwą UI) i pozwalają nam się komunikować ze store, który na ich podstawie wie, co zrobić.
  • Stany – stanem w NGXS można nazwać klasy, które posiadają specjalne dekoratory z metadatą oraz mapowaniem akcji.
  • Selektory – ostatnim elementem architektury są oczywiście selektory i podobnie jak w NgRx są to zwykłe funkcje, za pomocą których możemy pobrać odpowiedni „wycinek” danych z naszego globalnego kontenera stanu.
NGXS

RxJS

Przejdźmy zatem do ostatniej alternatywy, którą jest RxJS. W porównaniu do innych bibliotek, które pozwalają zarządzać stanem za pomocą akcji, reducerów oraz nawet efektów, RxJS sam w sobie nie daje nam takich możliwości.

Biblioteka RxJS została stworzona do obsługi asynchronicznych eventów, za pomocą wzorca observable, który daje nam większe możliwości i wyższy poziom abstrakcji, jeśli chodzi o ich obsługę. NgRx, Akita, NGXS rozwiązują zupełnie inne problemy niż sam RxJS. I chociaż RxJS stworzony był zupełnie do czegoś innego, to te biblioteki z niego korzystają. W bardzo małych aplikacjach można oczywiście użyć RxJS nawet bez potrzeby używania bibliotek odpowiadających za stan aplikacji, ale jest to wyzwanie trudne i na pewno nie dla osób początkujących.

Oczywiście, znać RxJS warto, ponieważ jest on używany w większości z wymienionych bibliotek, ale na początek przygody z zarządzaniem stanem aplikacji rozsądniej będzie wybrać inne rozwiązanie. Należy tutaj pamiętać, że w innych rozwiązaniach mamy dostępne przeróżne dodatkowe narzędzia, akcje, selektory, side effects, narzędzia developerskie, a w tym przypadku musimy poradzić sobie sami, co jest naprawdę nie lada wyzwaniem.

Podsumowanie

Jak widać, jednym z kluczowych wyzwań, które napotkamy przy tworzeniu aplikacji internetowych, jest szybkie gromadzenie danych i stabilność działania. W dzisiejszych czasach sporo osób stawia na rozwiązanie bazujące na Redux, które umożliwia modyfikację stanu i pozwala na jednokierunkowy przepływ danych. Jeśli aplikacja jest bardzo dynamiczna, sporo się w niej dzieje po stronie frontendu oraz przetwarzamy i wyświetlamy wiele danych pomiędzy komponentami, to warto rozważyć wykorzystanie NgRx. Osobiście w takim przypadku polecam to rozwiązanie, ponieważ pozwala uniknąć różnych charakterystycznych problemów, a zyskamy gotowe i sprawdzone narzędzia, z których korzysta się w wielu projektach.

Mam nadzieję, że przygotowane przeze mnie zestawienie zachęci was do zagłębienia się w temat zarządzania stanem, a może nawet spróbowania zaimplementowania jednego z tych rozwiązań w budowanej aplikacji. Liczę, że ten artykuł ułatwi wybór rozwiązania, które będziecie w stanie sami zaimplementować, posiłkując się wiedzą, którą posiadacie.

NGRX

Autorem wpisu jest:

Senior Front-End Developer

Od młodych lat interesował się wszystkim, co było związane z komputerami. Frontendem zajmuje się od 2013 roku. Paweł specjalizuje się w Angularze, na co dzień rozwijając duże systemy programistyczne. Lubi podejmować trudne wyzwania, inicjatywy oraz rozmawiać o programowaniu.

Dodaj komentarz: