Zdjęcie artykułu

🥇 Zarządzanie stanem w React. Redux toolkit czy Zustand?

10m
stan
biblioteki

Intro

Przeanalizujemy główne cechy Redux Toolkit i Zustand oraz przedstawimy ich wady i zalety. Przybliżymy różnice w podejściu i składni tych narzędzi.

Wybór biblioteki do state managementu w React

Jest to bardzo problematyczny i skomplikowany temat. Długo lawirowałem pomiędzy Redux toolkit, a ContextAPI. Po jakimś czasie zawsze dochodziłem do tego samego pytania - czy jest jakaś alternatywa? Jest ich całkiem sporo i jakbym miał wymieniać, to nie starczyło by mi dnia.

Szukałem czegoś bardzo prostego, produkującego małą ilość boilerplate i wspierającego modułowe podejście - bo to zapewnia mały rozmiar aplikacji oraz jest wygodne w utrzymaniu. I znalazłem!

Dziś zajmiemy się porównaniem możliwości Zustand oraz Redux toolkit.

Założenia Redux

  • Jeden store
  • Zmiany w store są wykonywane za pomocą funkcji dispatch
  • Do funkcji dispatch należy przekazać akcję (obiekt mający unikalny klucz oraz dodatkowe dane)
  • Implementuje wzorzec CQRS
  • Bazuje na architekturze FLUX (modyfikuje ją - zamiast wielu store mamy jeden)
  • Implementuje reducery (sprawdzają typ podanej akcji i zmieniają stan w oparciu o przekazane dane)
  • Posiada middleware
  • Wszystkie reducery są finalnie scalane do jednego
  • Funkcja subscribe działa dla całego store
  • Posiada selektory
  • Struktura monolitu - jak wielka baza danych
  • Brak modułowości
  • Wymaga wrappera (StoreProvider) - coś co zintegruje Reduxa z Reactem

Elementy składowe:

  • Store (jeden)
  • Reducery
  • Middleware
  • Akcje
  • Action creatory - funkcje tworzące obiekty akcji
  • Selektory
  • Hooki do odczytu oraz modyfikacji stanu
  • Dispatcher
  • Slice (w przypadku Redux toolkit)
  • Acts (w przypadku Redux toolkit)

Założenia Zustand

  • Więcej niż jeden store
  • Zmiany w store dokonujemy za pomocą akcji - zwykła funkcja
  • Implementuje architekturę FLUX
  • Brak reducerów
  • Brak funkcji dispatch
  • Brak CQRS
  • Posiada middleware dla każdego store oddzielnie
  • Modułowość
  • Posiada funkcję subscribe per store
  • Posiada selektory
  • Nie wymaga wrappera

Elementy składowe:

  • Więcej niż jeden store
  • Akcje
  • Selektory
  • Middleware - per store
  • Hook do wywoływania akcji i odczytu stanu (jeden)

Setup dla Redux toolkit

Na początek spięcie Redux z React - zwróć uwagę na Provider i przekazany do niego store.

Ładowanie

Teraz czas na slice, który wygeneruje nam reducery oraz akcje.

Ładowanie

Finalnie i tak wszystkie reducery muszą być scalone do jednego - plik reducers.ts.

Ładowanie

No i jeszcze potrzebujemy pliku store.ts, w którym wszystko połączymy.

Ładowanie

Teraz kod w komponencie.

Ładowanie

Nie wiem jak Ty, ale ja widzę sporo kodu, który będzie jeszcze bardziej skomplikowany przy każdej nowej funkcjonalności.

Setup dla Zustand

Potrzebujemy store. To tyle! Będzie on miał zarówno stan, jak i zestaw akcji. Zwróć uwagę, że usePostsStore to hook!

Ładowanie

Teraz wystarczy zaimportować hook stworzony przez Zustand do dowolnego komponentu i skorzystać ze stanu lub akcji.

Ładowanie

Zwróć uwagę, jak mało było potrzebne, aby uzyskać dokładnie ten sam efekt co w Redux. To był tylko jeden plik!

Różnica w rozmiarze bibliotek

Na stronie Bundlephobia wynik dla Redux, React redux, Redux toolkit oraz Redux thunk to: 1.8kB + 4.9kB + 3.6kB + 236B = ~10,537kB.

Dla Zustand jest to 1.1kB. Dla internetu slow 3G będzie to:

Redux = ~223ms

Zustand = 23ms

Zustand jest biblioteką, która nie ma żadnych dodatkowych zależności i nie będzie ich wymagała do uzyskania tych samych funkcjonalności, które oferuje ekosystem Redux (cztery wymienione biblioteki - patrz wyżej).

Boilerplate, a rozmiar aplikacji

Rozmiary bibliotek to jedno, ale istotny jest również wpływ na rozmiar aplikacji kodu, który musimy w nich napisać. Dla implementacji funkcjonalności z postami (patrz wyżej), rozmiary aplikacji (dla konfiguracji produkcyjnej) wynoszą:

Redux = 126kB (czysty projekt + feature postów).

Zustand = 117KB (czysty projekt + feature postów).

Teraz 30 funkcjonalności o identycznej implementacji.

Redux = 30 (liczba funkcjonalności) * 0.97kB (rozmiar boilerplate per funkcjonalność) = 29,1kB.

Zustand = 30 (liczba funkcjonalności) * 0.44kB (rozmiar boilerplate per funkcjonalność) = 13.2kB.

Zatem finalnie:

Redux = 126kB (rozmiar początkowy) + 29,1kB (rozmiar 30 funkcjonalności) = 155,1kB.

Zustand = 117KB (rozmiar początkowy) + 13.2kB (rozmiar 30 funkcjonalności) = 130,2kB.

Czasowo dla slow 3G:

Redux = ~3 456ms

Zustand = ~2 795ms

Operacje asynchroniczne w Redux toolkit

Jeżeli korzystamy z redux-thunk, to operacje asynchroniczne obsługujemy w plikach act.

Ładowanie

Teraz w naszym slice możemy obsłużyc zmiany stanu. Redux toolkit automatycznie wygeneruje odpowiednie akcje - pending, fulfilled oraz rejected, w których musimy zdefiniować jak zmienić stan.

Ładowanie

Warto zwrócić uwagę, że zmiana stanu jest odseparowana od logiki zapytania do API. W act definiujemy co wywołać oraz co zwrócić, a w slice obsługujemy jak zmienić stan w oparciu o rezultat.

Operacje asynchroniczne w Zustand

W Zustand operacje asynchroniczne obsługujemy zwykłą funkcją z adnotacją async.

Ładowanie

Zustand stosuje podejście, w którym modyfikacja wartości oraz ich odczyt jest w jednym miejscu - co sprawia, że kodu będzie mniej.

Bezpieczeństwo typów w Redux toolkit

W Redux toolkit typy są wnioskowane dla act na podstawie zwracanych wartości i jawnego typowania przekazywanych parametrów.

Ładowanie

A jak wygląda typowanie zwykłych akcji?

Ładowanie

No i na koniec typowanie stanu w slice.

Ładowanie

W Redux toolkit typy są częściowo dedukowane, więc zmieniając implementacje, zmieniamy też definicje typu dla akcji wywoływanych przez act. Jednocześnie jednak musimy stworzyć interfejs dla definicji stanu oraz wykorzystać PayloadAction do zdefiniowania payload dla konkretnej akcji, co wprowadza lekkie zamieszanie.

Bezpieczeństwo typów w Zustand

W Zustand wszystko definiujemy jawnie.

Ładowanie

W Zustand, dzięki jawnej definicji interfejsu, mamy gwarancję, że definicja typów ma jedno źródło prawdy. Jeżeli zmienimy interfejs, to wiemy, że należy dostosować implementację.

Analiza nakładu pracy w Redux toolkit

Dla każdej nowej funkcjonalności musimy:

  • Zaimplementować slice
  • Stworzyć typy
  • Zaimplementować acty
  • Zaimplementować akcje
  • Stworzyć plik z re-exportem akcji ze slice
  • Stworzyć plik z re-exportem reducerów ze slice
  • Zmodyfikować plik store
  • Popracować nad integracją z komponentami
  • Stworzyć testy dla każdej warstwy lub test integracyjny
  • Wielokrotnie przełączać się pomiędzy wieloma plikami

W przypadku jakiejś zmiany w logice biznesowej lub nazwie czegoś do czego odnoszą się elementy składowe, musimy przejrzeć wiele plików i upewnić się co do wprowadzonych zmian.

Analiza nakładu pracy w Zustand

Dla każdej nowej funkcjonalności musimy:

  • Stworzyć typy
  • Zaimplementować store (stan + akcje)
  • Zintegrować się z komponentami
  • Napisać testy jednostkowe lub test integracyjny
  • Przełączamy się pomiędzy dwoma lub trzema plikami maksymalnie

W Zustand operujemy głównie na pliku store i tam znajduje się wszystko czego potrzebujemy.

Implementacja redux-devtools-extension w Redux toolkit

Ładowanie

Implementacja redux-devtools-extension w Zustand

Reduxowe devtoolsy w Zustand? Przecież to nie Redux... Nie zmienia to faktu, że mamy taką możliwość. Tytuły dla akcji będą wygenerowane w oparciu o ich nazwy. Warto też zaznaczyć, że dla każdego store musimy je podpiąć oddzielnie - Zustand jest modułowy.

Ładowanie

Implementacja własnego middleware w Redux toolkit

Ten kod przechwyci wyjątek w kodzie i wyśle logi do bazy danych.

Ładowanie

Implementacja własnego middleware w Zustand

Ładowanie

W przypadku Zustand musimy jawnie używać middleware dla każdego store - patrz linia 25. Aby uniknąć duplikacji kodu możemy stworzyć fabrykę, która zajmie się tworzeniem store, który odrazu będzie miał podpięte middleware.

Ładowanie

To samo podejście możemy wykorzystać w przypadku devtools, które widzieliśmy wczesniej.

Przeczytaj artykuł o mockowaniu za pomocą fabryk, aby dowiedzieć się więcej o tym koncepcie.

Persystencja danych w Redux toolkit

Aby zapisać zawartość store do local/session storage musimy dodać bibliotekę. Instalujemy paczkę: npm i --legacy-peer-deps --save redux-persist. Kolejne +3kB.

Ładowanie

Teraz w główym pliku aplikacji app.tsx musimy dodać wrapper - PersistGate.

Ładowanie

Persystencja danych w Zustand

Ładowanie

Jeżeli chesz dowiedzieć się więcej o pracy z local/session storage w łatwy sposób, to zapraszam do artykułu: Working with local storage vs session storage.

Memoizacja i selektory w Redux toolkit

Selektory to funkcje, które mają za zadanie sprawdzić czy rzeczywiście zmieniła się wartość lub referencja dla danego fragmentu store. Wtedy możemy tę wartość zwrócić lub wykonać dodatkowe operacje - mapowanie do innego formatu danych. Wynik selektorów może być zapamiętany dla konkretnych argumentów. Jeżeli nie uległy zmianie, zostanie zwrócona stara wartość - dzięki temu React nie wykona re-renderu.

Ładowanie

A teraz kod komponentu:

Ładowanie

Nie wiem, czy zwróciliście uwagę, ale zainstalowaliśmy kolejną bibliotekę - reselect, która zwiększa rozmiar naszej aplikacji o kolejne 1.3kB.

Memoizacja i selektory w Zustand

Wystarczy wykorzystać stworzony store w ten sposób:

Ładowanie

A w komponencie robimy tak:

Ładowanie

Dodatkowo możemy to jeszcze usprawnić tworząc mechanizm do tworzenia selektorów w sposób automatyczny. Nie jest to temat tego artykułu, więc wspominam tylko o tym. Więcej o tym frykasie możesz znaleźć pod tym codesandboxem. Podobny mechanizm możemy stworzyć dla Reduxa - żeby nie było...

Ładowanie

Testowanie z Redux toolkit

Najpierw musimy zacząć od czegoś, co pozwoli nam zamockować store. Poniższa funkcja createStore pozwoli nam go stworzyć oraz da możliwość nadpisania jego wartośc per test. Natomist funkcja renderWithStore podłączy nam dowolny komponent do Reduxa.

Ładowanie

Teraz załóżmy, że chcemy przetestować jak działa nasz komponent PostsList, który korzysta wewnątrz z useAppSelector i useAppDispatch do wyświetlenia listy postów i do dodania postu.

Ładowanie

Jeżeli jesteście ciekawi tematów związanych z testowaniem, to zapraszam do lektury kursu React testing spellbook.

Testowanie w Zustand

Po każdym teście musimy mieć mechanizm do przywracania początkowego stanu. W innym przypadku, gdy go nie będzie, w kolejnych testach będziemy mieć wcześniej ustawiony stan - co jest niebezpieczne dla stabilności testów.

Ładowanie

A teraz przykład użycia przy testowaniu komponentu:

Ładowanie

Zarządzanie stanem w Redux toolkit

Redux toolkit wykorzystuje Immer.js, abyśmy mogli zmieniać stan w łatwy i przyjemny sposób.

Ładowanie

Czasami jednak chcemy zrestować stan lub go częściowo podmienić. Niestety Redux toolkit na to nie pozwala. Nie można wyłączyć zachowania Immer.js. Jeżeli chcemy uzyskać ten efekt, to nie obejdzie się bez jakiegoś dodatkowego mechanizmu, który musimy sami napisać.

Ładowanie

Zarządzanie stanem w Zustand

W Zustand mamy wybór. Możemy podmienić stan całkowicie lub nowy stan scalić z obecnym.

Ładowanie

A jak dorzucić Immera? Jeżeli jest taka potrzeba, to wystarczy użyć następującego middleware.

Ładowanie

Praca z akcjami w Redux toolkit

Czasami chcemy wywołać akcję poza ekosystemem Reacta. W Redux toolkit musimy zaimportować cały store oraz akcję i wywołać dispatch.

Ładowanie

Praca z akcjami w Zustand

Tu wystarczy deklaracja akcji, jej import oraz wywołanie.

Ładowanie

Odczyt stanu w Redux toolkit oraz Zustand

Aby nasłuchiwać na zmiany stanu zarówno w Redux toolkit jak i Zustand, możemy skorzystać z następującego API:

Ładowanie

Odczyt aktualnego stanu wygląda podobnie:

Ładowanie

Nie widać różnicy w API, ale jest diametralna różnica w sposobie działania. Redux będzie wywoływał callback subscribe przy wywołaniu jakiejkolwiek zmiany stanu, natomiast Zustand tylko wtedy, gdy zmieni się ten jeden, konkretny store.

Podobnie dla getState. Redux zwróci wszystko (cały stan aplikacji), a Zustand tylko store, do którego się odnosimy (stan funkcjonalności).

Code splitting i lazy loading w Redux toolkit

Code splitting oraz lazy loading to dwie popularne techniki, które mają zredukować czas poświecony na "pierwsze" załadowanie dowolnej aplikacji. Jaki jest sens ładować kod dla funkcjonalności administratora, jeżeli użytkownik nim nie jest?

W Redux toolkit na samym początku musimy mieć mechanizm do podmiany głównego reducera - to już mamy w bibliotece.

Ładowanie

Teraz musimy mieć możliwość dodania nowego slice.

Ładowanie

Teraz możemy wywołać w dowolnym miejscu:

Ładowanie

Więcej na ten temat możesz znaleźć w dokumentacji.

Code splitting i lazy loading w Zustand

Ze względu na modułową naturę, code splitting i lazy loading mamy praktycznie out of the box czy plug and play. Dzięki temu zarówno kod React jak i Zustand będzie znajdował się w jednym pliku, i możemy go w całości oddzielić od głównego, niezbędnego do działania aplikacji pliku index.js, który będzie ładowany na początku. Dodatkowo jest to przewidywalne w zachowaniu.

Ładowanie

Analizujemy runtime performance

W Redux toolkit korzystamy z obiektu TYP_AKCJI: FUNKCJA, co wygląda elegancko, ale w dalszym ciągu wywołujemy każdy reducer przy każdej akcji.

Ładowanie

W Zustand każdy store jest niezależny od siebie. Nie będzie zatem żadnych dodatkowych porównań.

Ładowanie

Teraz w momencie gdy mamy 30 reducerów w aplikacji, przy Redux wywołamy aż 29 dodatkowych czynności dla każdej wywołanej akcji. Czy jest to problem? Szczerze, to nie... Po prostu wspominam o tym. Sama operacja znajdowania akcji jest bardzo szybka, więc raczej nie ma się czym przejmować...

To w końcu Redux czy Zustand?

Z każdym dniem zdaję sobie sprawę, że prościej znaczy lepiej. Twierdzę tak dlatego, że nad projektami pracują ludzie na różnym poziomie. Redux potrafi być naprawdę skomplikowany i bardzo łatwo się jest na nim zwyczajnie połamać. Sam koncept jest świetny, ale moim zdaniem Zustand realizuje go poprzez prostsze API i wprowadza nowe możliwości - więcej niż jeden store.

Zustand rezygnuje również z konceptu CQRS (command query responsibility segregation), w którym odczyt danych jest odseparowany od sposobu wywołania na nich zmian - przez co znacznie zredukowany jest coupling i potencjalne refaktory będa prostsze, jak i samo utrzymanie kodu - przynajmniej w teorii.

Ładowanie

Ładowanie

W praktyce jednak skomplikowanie Reduxa powoduje powstawanie totalnego makaronu i widziałem to już w wielu projektach. Przez co zalety i koncepty, które wprowadza, przestają być "game changerem", a są tylko problemem - bo ciężko jest je zrozumieć.

Nie zniechęcam Cię do Reduxa, lecz pragnę podkreślić pytanie. Czy naprawdę potrzebujesz CQRS na frontendzie i czy na prawdę dodatkowe skomplikowanie, które wprowadza Redux cokolwiek Ci daje?

Jak pewnie widzieliście, różnic pomiędzy omawianymi technologiami jest wiele. Decyzję zostawiam Wam, Drodzy Czytelnicy. Przedstawiam kilka argumentów dlaczego ja stosuje Zustanda, zamiast Reduxa:

  • Mniejszy rozmiar paczki i kodu wynikowego funkcjonalności
  • Modułowość
  • Te same możliwości co Redux (bez CQRS i z wieloma storami)
  • Mniej kodu potrzebnego do napisania
  • Kod jest czytelniejszy i łatwiejszy do zrozumienia dla początkujących
  • Code splitting jest łatwo osiągnąć
  • Devtools takie jak w Redux
  • Brak wrappera (StoreProvider)

Podsumowanie

To była długa podróż, teraz rozumiemy istotne różnice między wspomnianymi technologiami. Z uzyskanymi spostrzeżeniami dotyczącymi moich argumentów i opinii nadszedł czas, aby wyciągnąć własne wnioski - tak, mówie o Tobie.

Moim faworytem jest Zustand. Mam nadzieję, że przytoczone porównanie i argumenty były pomocne.

Jeśli chcesz zgłębić koncepcję selektorów, polecam przeczytanie artykułu ⭐ Working with selectors in Zustand and Redux .

Jestem z tych, którzy publikują codziennie!

Mam nadzieję, że mój wpis Ci się spodobał. Jeżeli tak jest, to zapraszam Cię na mój LinkedIn, gdzie publikuję codziennie.

Komentarze

Przejrzyj komentarze artykułu i dodaj swoją opinię.

stworzono: 16-07-2023
zmieniono: 01-12-2023