Rosnąca popularność architektury Flux oraz implementującego ją Reduxa sprawiła, że praktycznie w każdym projekcie opartym na React możemy spotkać wspomnianą bibliotekę. Czy jest to słuszne podejście? Zdania są podzielone. Aby wyrobić sobie opinię, zachęcam do lektury artykułu.
Stan komponentu
Zanim przejdziemy do oceny, wróćmy do korzeni Reacta. Fundamentami każdego komponentu są właściwości (props) oraz stan (state). To właśnie stan odpowiada głównie za przechowywanie danych, które następnie chcemy użyć w naszej aplikacji. To on powoduje ponowne wyrenderowanie komponentu w przypadku jakiejkolwiek zmiany.
React od wersji 16.8 pozwala nam korzystać z tzw. hooków. Dzięki temu możemy w pełni zrezygnować z komponentów klasowych, a skupić się na komponentach funkcyjnych. Dla przypomnienia, każdy z hooków musi zaczynać się od słowa ‘use..’ i może być użyty jedynie w komponencie lub w innym hooku.
Jednym z najczęściej używanych hooków jest useState, odpowiadający za zarządzanie stanem komponentu. Jego składnia wygląda następująco:
const [stan, funkcja_zmieniająca_stan] = useState(wartość_początkowa).
Weźmy na przykład prosty dialog. Przy pierwszym wyrenderowaniu aplikacji nie chcemy, aby był on wyświetlany. Następnie, po kliknięciu na przycisk, dialog powinien się otworzyć. Możemy to zapisać w następujący sposób:
Jak możemy zauważyć, implementacja jest bardzo prosta i przejrzysta. Każda zmiana stanu odpowiednio renderuje komponent i moglibyśmy stwierdzić, że żadne dodatkowe narzędzie nie jest nam potrzebne. Miejmy jednak na uwadze, że jest to bardzo trywialna aplikacja, wyświetlająca jedynie statyczne informacje. Co jednak w przypadku, jeśli nasz program pobierałby informacje z backendu, a następnie wyświetlał je w kilku różnych miejscach?
Context API
Z pomocą przychodzi nam kontekst, którego aktualną wersję wprowadzono w React 16.3. Jest to mechanizm, który upraszcza przekazywanie danych pomiędzy komponentami, niwelując przy tym zjawisko tzw. ‘prop-drillingu’. W React informacje przekazywane są jednokierunkowo – od komponentów rodzica do komponentów dziecka. Przy bardziej rozbudowanych aplikacjach powstające drzewo zależności potrafi być naprawdę obszerne. Często określoną informację powinniśmy wyświetlić w najniższym szczeblu drzewa.
Zakładając, że dane pobieramy raz, w komponencie rodzica, jesteśmy zmuszeni przekazywać propsy przez pośrednie komponenty, które nawet z tych danych nie korzystają. Pełnią one jedynie rolę pośrednika. Zmniejsza to czytelność kodu, a także powoduje niepotrzebne rendery w przypadku zmian.
Opisane zjawisko ‘prop-drillingu’ jest eliminowane dzięki użyciu kontekstu. Mechanizm ten jest bardzo prosty w implementacji. Wymaga wykonania 3 kroków:
- Stworzenie kontekstu, korzystając z funkcji createContext, do której przekazujemy domyślną wartość.
- Stworzenie wrappera, który opakowuje interesujące nas komponenty, aby wszystkie z nich mogły zasubskrybować się do zmian kontekstu. Do tego celu powinniśmy użyć komponentu Providera, istniejącego w obiekcie naszego kontekstu. Tam przekazujemy aktualną wartość, którą powinien przechować kontekst.
- Użycie wrappera nad komponentami, które powinny mieć dostęp do kontekstu (zazwyczaj na szczycie drzewa naszej aplikacji).
W tak skonfigurowanej aplikacji jesteśmy w stanie (gra słów niezamierzona) w łatwy sposób skorzystać ze współdzielonych danych na dowolnym poziomie zagnieżdżenia komponentu. Do tego celu możemy użyć kolejnego z hooków, jaki daje nam do dyspozycji React – useContext. Jedyne, co musimy przekazać, to nazwa kontekstu, z którego chcemy skorzystać (w aplikacji możemy mieć ich wiele). Następnie możemy odczytać interesującą nas wartość, którą planujemy wyświetlić:
Proste, prawda? Musimy jednak pamiętać, że sam Context API nie jest mechanizmem do zarządzania stanem! Jest to zestaw narzędzi, które pomagają przekazywać dane wzdłuż drzewa komponentów. Jak można zauważyć w poprzednich kawałkach kodu, do samego zarządzania stanem użyty został podstawowy hook Reacta – useState. Warto o tym pamiętać, ponieważ często kontekst jest mylnie nazywany narzędziem do zarządzania stanem aplikacji. Tymczasem niczym on nie zarządza, jedynie pozwala nam dostać się do wymaganych danych, bez konieczności mozolnego przekazywania zależności w dół drzewa komponentów.
Redux
Przy coraz bardziej rozbudowanych aplikacjach korzystających z coraz większej liczby danych, użycie kontekstu może być problematyczne. Przede wszystkim nie jest on przystosowany do częstych zmian. Przyczynia się to do spadku wydajności, a także szybkości działania aplikacji. W przypadku wielu kontekstów, jesteśmy również zmuszeni do opakowania aplikacji każdym z nich, co zmniejsza czytelność kodu.
Naprzeciw tym problemom wychodzi Redux, pełnoprawna biblioteka zarządzania stanem, najczęściej stosowana właśnie z Reactem. Bazuje ona na architekturze Flux, o której więcej możecie przeczytać w artykule na naszym blogu.
Opisując założenia Reduxa, można wyodrębnić 3 główne zasady:
- Globalny stan aplikacji powinien być przechowywany w jednym miejscu jako obiekt zwany magazynem (store).
- Istniejący stan aplikacji powinien być niezmienialny (immutable), tylko do odczytu. W przypadku aktualizacji danych, powinniśmy pracować na kopii obiektu, a następnie zmienioną kopię zwracać jako aktualny stan. Dzięki temu łatwo można śledzić zachodzące zmiany, a także uniknąć przypadkowego nadpisania danych. Jedyną możliwością zmiany stanu jest wysłanie (dispatch) odpowiedniej akcji (action), czyli obiektu opisującego konkretne zdarzenie w aplikacji.
- Za aktualizowanie stanu odpowiedzialny jest tzw. reducer. Jest to „czysta” funkcja (pure function – dla tych samych argumentów zawsze zwraca taką samą wartość oraz nie powoduje skutków ubocznych, np. w postaci modyfikacji zmiennych poza funkcją), która przyjmuje za argumenty aktualny stan oraz akcję. Na ich podstawie zwraca kolejny, nowy stan.
To, że stan powinien być niezmienialny, a jednak go aktualizujemy, może być początkowo mylące. Warto pamiętać, żeby nigdy nie modyfikować istniejącego obiektu stanu, natomiast zawsze pracować na jego kopii.
Przejdźmy do stworzonego wcześniej dialogu i załóżmy, że chcemy dodać kolejne pole z danymi użytkownika – tym razem jego wiek. Wartość ta może być zmieniana przy pomocy przycisków + oraz -, a także po wpisaniu wartości do pola tekstowego. Zobaczmy więc, jakie kroki musimy wykonać, aby zaimplementować tę logikę przy pomocy Reduxa.
Dla celów edukacyjnych użyłem podejścia, które przez twórców uznawane jest za nieaktualne (deprecated), ale lepiej pokazuje założenia biblioteki. Pod koniec artykułu krótko opiszę nowoczesne standardy. Zacznijmy od tego, że jako zewnętrzną paczkę, Reduxa najpierw musimy zainstalować. W przypadku Reacta powinniśmy zainstalować 2 biblioteki – redux, a także react-redux. Kolejnym krokiem jest stworzenie naszego magazynu (store). Do tego celu będzie nam potrzebny także reducer, który należy przekazać przy kreacji magazynu.
Reducer przyjmuje 2 argumenty – aktualny stan oraz akcję. Jeśli słowo reducer brzmi znajomo, to dlatego, że pochodzi ono od metody Array.reduce(). W ten sam sposób przyjmuje za argumenty aktualny stan, a także kolejną wartość, którą należy obsłużyć. Można powiedzieć, że reducer „zbiera” wszystkie akcje, aby na sam koniec sprowadzić je jednej końcowej wartości w postaci stanu. Każda akcja musi mieć określony typ, na podstawie którego reducer decyduje, w jaki sposób stan powinien być zaktualizowany. Akcja może mieć także jakiś ładunek informacji (payload).
Jak możemy zauważyć, w przypadku aktualizacji stanu, zwracana jest jego kopia, nigdy nie modyfikujemy bezpośrednio istniejącego stanu! Gdy przekazana do reducera akcja nie jest znana, a tym samym żadna wartość nie jest aktualizowana, zwrócony powinien zostać aktualny stan.
Stworzyliśmy magazyn oraz funkcję obsługującą przychodzące zmiany. Teraz pora użyć przygotowanych danych w komponentach. W tym celu powinniśmy opakować naszą aplikację przy użyciu komponentu Providera (podobnie jak w przypadku kontekstu), tym razem importowanegoz biblioteki react-redux. Do komponentu należy przekazać stworzony store.
Dzięki temu umożliwiliśmy komponentom naszej aplikacji dostęp do magazynu z globalnymi danymi. Spróbujmy je w takim razie wykorzystać. Do tego celu służy hook useSelector, którego argumentem jest funkcja określająca jaką część stanu chcemy wykorzystać. Następnie możemy używać danej wartości jak zwykłej zmiennej.
Wysyłanie akcji
Przejdźmy teraz do bardziej interesującej kwestii – wysłania akcji w celu aktualizacji stanu. Tutaj powinniśmy skorzystać z kolejnego hooka biblioteki react-redux – useDispatch. Wywołanie tego hooka daje nam dostęp do funkcji, dzięki której możemy wysyłać akcje do reducera. Naciskając na przycisk ‘+’, chcielibyśmy zwiększyć wiek użytkownika. W przypadku kliknięcia w przycisk ‘-’ wykonać adekwatną operację.
Aby umożliwić te zdarzenia, należy przypisać odpowiednie handlery dla eventów ‘onClick’. W handlerach używamy funkcji dispatch, do której przekazujemy odpowiednie obiekty akcji. Każdy z tych obiektów musi zawierać typ, na podstawie którego reducer zaktualizuje stan. Typ wysłanej akcji jest taki sam, jak uprzednio użyty w stworzonym reducerze. Na tej podstawie może on odpowiednio zareagować. Obiekt akcji może także zawierać jakąś informację w postaci payloadu. W naszym przypadku jest to wpisany przez użytkownika wiek, który przekazywany jest do nowego stanu aplikacji (nawiasem mówiąc, warto takie update’y opakować w funkcję debounce, aby uniknąć niepotrzebnych aktualizacji stanu przy każdej interakcji użytkownika).
Podsumowując, stworzyliśmy magazyn (store), który jest naszym jedynym źródłem prawdy. Do magazynu przekazaliśmy reducera, który określa, jak stan powinien być aktualizowany w czasie (nie bezpośrednio zmieniany!), na podstawie przychodzących akcji. Akcje opisują, co w naszej aplikacji się wydarzyło. Wydarzenia te wysyłane są zazwyczaj na podstawie interakcji użytkownika z naszym UI (wciśnięcie przycisku, zmiana wartości pola) przy użyciu dispatchera. Aby wykorzystać istniejące dane przechowywane w magazynie, wystarczy zasubskrybować się do zmian przy użyciu selektora. Każda zasubskrybowana zmiana wpływa na ponowne renderowanie UI. Proste, prawda?
Ciekawostką jest, że react-redux do przekazywania danych pomiędzy komponentami używa kontekstu. Przekazuje instancję magazynu, a nie aktualną wartość stanu, co korzystnie wpływa na performance.
Niezmienialność stanu aplikacji (tworzenie nowego stanu przy każdej akcji) ma także ważną implikację – dzięki niej możemy skorzystać z takich narzędzi jak Redux DevTools, które pozwalają nam odtworzyć historię poszczególnych zmian w czasie. Bardzo przydatna rzecz przy debugowaniu.
Aktualnie zalecane podejście do implementacji Reduxa
Na koniec obiecane nowoczesne (aktualnie polecane przez twórców) podejście do tematu implementacji Reduxa. Ułatwia pracę, ponieważ nie zmusza implementującego do każdorazowego tworzenia kopii istniejącego stanu, a także zapobiega powtarzającym się błędom. Poprzednie przykłady służyły głównie pokazaniu jak Redux działa, natomiast to jak powinniście go implementować w istniejących aplikacjach, opisuje poniższe podejście.
Opiera się ono na bibliotece @reduxjs/toolkit (nazywanej też skrótowo RTK). W tym wypadku tworzymy store przy użyciu funkcji configureStore, do której przekazujemy naszego reducera. Zaletą tego jest automatyczna konfiguracja podstawowych narzędzi do pracy z Reduxem, m.in ustalenie połączenia do wspomnianego wcześniej Redux DevTools, a także wbudowane oprogramowania pośrednie (middleware), takie jak redux-thunk.
Tworzenie samego reducera także zostało uproszczone. Logika oparta jest na podzieleniu większego stanu na kawałki (slice). Wykorzystana zostaje funkcja createSlice(), do której przekazane zostają nazwa, stan początkowy oraz obiekt reducers, który zawiera funkcje, obsługujące poszczególne akcje. Jest to analogia do konstrukcji switch/case, którą zaimplementowaliśmy wcześniej. Domyślna wartość ‘starego’ switcha jest już za nas automatycznie przekazywana.
Co ciekawe createSlice() pozwala nam mutować stan! Jest to jedynie pozorne mutowanie, gdyż pod spodem działa biblioteka Immer, która odnotowuje wszelkie zmiany, a następnie zwraca za nas nowy, zaktualizowany, lecz także niezmienialny stan. Wykonuje za nas pracę, dzięki czemu nie musimy skupiać się na kopiowaniu każdej właściwości obiektu, szczególnie tej głęboko zagnieżdżonej. Kolejnym plusem jest to, że akcje są automatycznie tworzone, na podstawie funkcji przekazanych do obiektu reducers. Ze stworzonego ‘slice’a’ możemy wyeksportować wygenerowany reducer, który zostanie użyty przy tworzeniu magazynu (store).
Wykorzystanie danych przechowywanych w magazynie, a także wysyłanie aktualizacji stanu pozostaje takie samo jak w klasycznym podejściu – przy użyciu hooków useSelector oraz useDispatch. W przypadku tego pierwszego, musimy pamiętać, aby najpierw sprecyzować reducer, do którego się odwołujemy. Jego nazwa musi być identyczna, jak ta przekazana do obiektu reducers przy konfiguracji store’a.
Wykorzystanie useDispatch zostało uproszczone – pozostaje nam jedynie przekazać automatycznie wygenerowaną akcję, którą importujemy z kawałka (slice) naszego stanu. Akcja ta ma postać funkcji, której wywołanie zostaje przekazane do dispatcha. Chcąc przekazać ładunek (payload), odpowiednią wartość przekazujemy do wywołania akcji. Dzięki temu nie jesteśmy zmuszeni do żmudnego tworzenia obiektów z typem, a czasem payloadem danego zdarzenia w aplikacji.
Użycie biblioteki @reduxjs/toolkit w dużym stopniu upraszcza pracę z Reduxem, a także niweluje powszechnie popełniane błędy. Mimo wszystko uważam, że warto znać podstawy działania biblioteki, co tym bardziej pozwala docenić zalety nowoczesnych rozwiązań.
Czy Redux to konieczność?
React zapewnia nam wiele możliwości, jeśli chodzi o zarządzanie stanem. Tylko od nas zależy w jaki sposób je wykorzystamy. Przede wszystkim sprecyzujmy nasze wymagania, a następnie dopasujmy do tego narzędzia, nie odwrotnie!
Czy powinniśmy korzystać z Reduxa? Oczywiście. Czy powinniśmy dołączać go do zależności przy każdej tworzonej aplikacji? Niekoniecznie.
Jak pokazałem w artykule, przy użyciu wbudowanych funkcjonalności Reacta jesteśmy w stanie poradzić sobie ze średnio skomplikowanymi problemami, gdzie dane są wymagane w rozproszonych miejscach aplikacji. Należy jednak pamiętać, że w przypadku coraz bardziej rozbudowanych serwisów, możemy stracić kontrolę nad tym, jak nasz stan zmienia się w czasie. Gdy widzimy, że nasz główny komponent zaczyna być opakowywany w coraz więcej kontekstów, a każdy z nich zawiera pokaźną liczbę funkcji aktualizujących stan, wtedy najwyższa pora zwrócić się do naszego starego przyjaciela, Reduxa.
***
Jeśli interesuje Cię tematyka Reacta i Fluxa, zajrzyj również do innych artykułów naszych ekspertów.
Zostaw komentarz