Strona główna / Artykuły

Zustand kontra Redux Toolkit kontra Context: menedżery stanu w React według Renders i rozmiaru pliku

Aplikacja do zarządzania koszykiem skonstruowana przy użyciu ośmiu wzorców stanu React, sprawdzona pod kątem marnotrawstwa renderowań oraz rozmiaru spakowanego pliku po kompresji: co mówią liczby o Zustand i Redux Toolkit.

Wydawca: REACTAPP.TOP9 min czytania2182 słów

Źródła: I Built the Same React App With All 8 State Management Patterns. The Numbers Picked Three Winners.

Który menedżer stanu React powinieneś wybrać? To pytanie pojawia się co kilka miesięcy i w większości przypadków zależy od osobistych preferencji. Lepszą odpowiedź uzyskasz, budując tę samą małą funkcjonalność przy użyciu każdego z powszechnie stosowanych wzorców, ustalając zachowanie za pomocą jednego wspólnego zestawu testów oraz porównując dwie rzeczy, które faktycznie się różnią: częstotliwość ponownego renderowania komponentów oraz ilość kilobajtów dodawanych przez każdą z opcji. Poniżej znajduje się opis tego eksperymentu z ośmioma wzorcami – od Zustand i Redux Toolkit po zwykły Context – wyjaśnienie, dlaczego liczby są takie, jakie są, oraz instrukcja, którą możesz ponownie zastosować do swojego własnego stanu.

Jak został przygotowany eksperyment

Aplikacja testowa jest celowo mała: koszyk zakupów z przyciskiem jabłka, przyciskiem bananu, sumą aktualną oraz znacznikiem tematu, którego wartość nigdy się nie zmienia. Każda implementacja jest sprawdzana za pomocą tego samego testu behawioralnego, który trzy razy kliknie w przycisk jabłka i dwa razy w przycisk bananu, a następnie sprawdzi identyczne wyniki. Podczas wykonywania testu licznik rejestruje każdą renderizację każdego komponentu. Oddzielnie esbuild mierzy, ile każdy z tych rozwiązań dodaje do spakowanego i skompresowanego pliku, z wyłączeniem React, ponieważ każda opcja generuje ten sam koszt.

Osiem kandydatów to:

  • używane useState przekazywane przez props
  • Context w połączeniu z useReducer
  • Redux Toolkit
  • Zustand
  • Jotai
  • Valtio
  • MobX
  • ręcznie napisany magazyn danych oparty na useSyncExternalStore, bez żadnych zależności

Wszystkie osiem przekracza wymagania wspólnego kontraktu, więc wszelkie różnice dotyczą jedynie kosztów, a nie poprawności:

✓ lifted useState        › passes the shared cart contract
✓ Context + useReducer   › passes the shared cart contract
✓ Redux Toolkit          › passes the shared cart contract
✓ Zustand                › passes the shared cart contract
✓ Jotai                  › passes the shared cart contract
✓ Valtio                 › passes the shared cart contract
✓ MobX                   › passes the shared cart contract
✓ useSyncExternalStore   › passes the shared cart contract

Tests  8 passed (8)

Tutaj znajdują się wersje biblioteki oraz środowisko działania użyte do pomiarów, a także repozytorium zawierające osiem implementacji, wspólne testy oraz oba skrypty pomiarowe:

Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.

Dwie opcje zapewniające uczciwe porównanie

Oba te przypadki wynikają z błędów występujących w rzeczywistych aplikacjach, a nie z zasad etyki testowania wydajności.

Po pierwsze, każda implementacja tworzy swój magazyn danych wewnątrz komponentu, a nie na poziomie modułu. Dzięki temu każda uruchomiona aplikacja zaczyna od zera, a żaden stan nie ucieka pomiędzy kolejnymi testami.

Po drugie, element tematyczny istnieje wyłącznie jako neutralny element. Subskrybuje się na wartość, której nigdy nie zmienia żaden kliknięcie, więc każde renderowanie, które odbywa się podczas testu, jest marnotrawstwem zasobów, za które można winić jedynie wzorzec stanu.

Co celowo nie jest objęte zakresem

Konkurs dotyczy wyłącznie wspólnego stanu klienta. TanStack Query nie jest brany pod uwagę, ponieważ zarządza pamięcą cache danych serwera, co to inny problem o innych zasadach. Stan URL również nie jest uwzględniany, ponieważ pasek adresu należy do routera. Twoja aplikacja prawdopodobnie potrzebuje obu rozwiązań, ale żadne z nich nie konkuruje w tym konkretnym konkursie.

Niespodzianka numer jeden: rozmiar kodu jest niemal identyczny

Zanim przejdziemy do interesujących liczb, warto zwrócić uwagę na jedną nudną informację. Osiem wersji implementacji ma od 39 do 58 linii kodu. Najbardziej rozbudowana opcja, Redux Toolkit z 58 liniami, jest zaledwie o dziewiętnaście linii dłuższa od najprostszego możliwego podejścia, lifted state z 39 liniami. W takim skali argument dotyczący „boilerplate’u”, który dominuje w tak wielu dyskusjach na temat zarządzania stanem, odpowiada zaledwie dziewiętnastu liniom kodu. Istotne różnice znajdują się gdzie indziej i stają się widoczne dopiero przy użyciu narzędzi diagnostycznych.

Tabela wyników renderowania

Oto ile razy każdy komponent został wyświetlony podczas pięciu kliknięć, włączając początkowe załadowanie:

5 clicks (3 apple, 2 banana)     Apple  Banana  Total  Theme(idle)

lifted useState                    6      6       6       6
Context + useReducer               6      6       6       6
Redux Toolkit                      4      3       6       1
Zustand                            4      3       6       1
MobX                               4      3       6       1
useSyncExternalStore               4      3       6       1
Jotai                              5      4       7       2
Valtio                             2      2       2       1

Z tych kolumn wynikają trzy odrębne historie.

Lifted state i Context przerysowują wszystko

Dzięki zastosowaniu useState w trybie lifted oraz jednego Contextu, każdy komponent jest przerysowywany przy każdym kliknięciu. Emblemat tematu był wyświetlany sześć razy, mimo że jego wartość nigdy się nie zmieniła, a przycisk bananowy pojawiał się przy każdym kliknięciu na jabłko. To jest konkretny mechanizm stojący za powszechnym ostrzeżeniem, że Context nie jest menedżerem stanu. Funkcja useContext subskrybuje komponent pod całą wartość Contextu, więc gdy tylko ta wartość zmienia swoją tożsamość, wszystkie komponenty ją używające są przerysowywane. Umieszczenie całego stanu w jednym Contextu jest w praktyce równoznaczne z lifted state z dodatkową warstwą.

Zwykłym rozwiązaniem jest rozdzielenie stanu na kilka Contextów lub zastosowanie mechanizmu memoizacji dla komponentów, co nie zostało tu sprawdzone. Przykład pokazuje Context w formie, w której jest najczęściej używany: jeden dostawca przechowujący jedną wartość.

Cztery API, jeden identyczny wynik

Redux Toolkit, Zustand, MobX oraz ręcznie napisany store generują dokładnie tę samą strukturę: każdy komponent jest renderowany raz podczas inicjalizacji, a ponownie tylko wtedy, gdy zmienia się dane, które odczytuje. Ich interfejsy API w ogóle nie przypominają się, a mimo to ich zachowanie w czasie wykonywania jest identyczne, ponieważ wszystkie cztery opierają się na tej samej podstawowej koncepcji. Komponenty słuchają wybranego fragmentu stanu, a nie całego store’u, i są powiadamiane tylko wtedy, gdy ten fragment ulega zmianie. Aby lepiej zrozumieć, jak działa to wybieranie i sprawdzanie równości w jednej z tych bibliotek, zapoznaj się z tym, jak React Redux decyduje, kiedy ponownie renderować komponenty.

Podejrzanie niskie liczby Valtio

Kolumna Valtio na pierwszy rzut oka przypomina uszkodzony licznik: dwa renderowania dla przycisku klikniętego trzy razy. Liczby są poprawne. Valtio grupuje powiadomienia o zmianach w kolejce mikrozadań, więc kilka szybkich, synchronicznych aktualizacji łączy się w jedno renderowanie na komponent, a ostateczne wartości pozostają prawidłowe.

Istnieje jednak ważna zastrzeżenie. Osoba klikająca w normalnym tempie powoduje jedno renderowanie na każde kliknięcie, ponieważ każde kliknięcie kończy się przed rozpoczęciem następnego. Zaleta ta pojawia się tylko wtedy, gdy aktualizacje przychodzą falami, jak to ma miejsce przy wiadomościach WebSocket, danych strumieniowych lub zdarzeniach przeciągania. Jeśli twoja aplikacja ma taką charakterystykę, ta kolumna wymaga uważnej analizy.

Spójne dodatkowe renderowanie Jotai

Jotai wykonał każdy komponent raz więcej niż grupa oparta na selektorach, w tym dwa wykonywania dla znacznika tematu idle. Jego stopień szczegółowości jest prawidłowy, ponieważ komponenty nadal reagują tylko na używane przez nie elementy, a dodatkowe wykonywanie jest jednakowe we wszystkich kolumnach. Ten wzorzec wskazuje na problem w początkowej sekwencji montowania z Providerem z magazynem danych, a nie na wyciek subskrypcji, jednak dokładna przyczyna nie została ustalona. Należy traktować to jako otwarte pytanie, a nie werdykt przeciwko Jotai.

Waga pliku skompresowanego

Oto, ile każdy wzorzec dodaje do skompresowanego pliku, uwzględniając zarówno własny kod wzorca, jak i jego bibliotekę, przy czym React jest traktowany jako element zewnętrzny:

lifted useState          0.4 KB
useSyncExternalStore     0.5 KB     (zero dependencies)
Context + useReducer     0.5 KB
Zustand                  0.7 KB
Valtio                   2.7 KB
Jotai                    4.4 KB
Redux Toolkit           10.7 KB
MobX                    13.2 KB

Najcięższa opcja jest 33 razy większa od najlżejszej. Innymi słowy, Redux Toolkit w połączeniu z MobX zajmuje 23,9 KB, podczas gdy pozostałe sześć rozwiązań łącznie zajmuje 9,2 KB.

Ciekawe jest to, jak ta lista wpływa na wyniki renderowania. Tylko dwa rozwiązania łączą idealne zachowanie podczas renderowania z rozmiarem poniżej jednego kilobajtu: Zustand oraz ręcznie napisany store. Redux Toolkit osiąga ten sam poziom renderowania przy rozmiarze 10,7 KB, a MobX – 13,2 KB. To niekoniecznie jest wadą tych narzędzi. To po prostu cena, a cena ma sens tylko wtedy, gdy wiemy, co za nią otrzymujemy.

Trzy praktyczne rozwiązania i ich koszt

Dla wspólnego stanu klienta w typowej aplikacji pomiary wskazują na trzy narzędzia, które warto rozważyć.

Zustand jako standard

Zustand zapewnia doskonałą szczegółowość renderowania przy rozmiarze 0,7 KB i 54 liniach kodu, a jego API prawie nie wymaga wyjaśnień dla nowego współpracownika – jest to funkcja typu hook, która przyjmuje selektor. W tym teście jego działanie było zbliżone do zachowania Redux podczas wykonywania operacji, przy rozmiarze około jednej piętnastej.

Ten wynik zależy od jednej zasady: zawsze należy wybierać konkretne fragmenty danych. Wywołanie typu useCart(s => s) powiązuje komponent z całym magazynem danych, co powoduje powtórzenie problemu związанego z Context. Efektywność pochodzi od pisania precyzyjnych selektorów, a nie od nazwy biblioteki. Jeśli selektor zwraca nowy obiekt lub tablicę przy każdym wywołaniu, konieczny jest również pomocnik do sprawdzania powierzchownej równości, w przeciwnym razie każda aktualizacja będzie wyglądać jak zmiana.

Ręcznie napisany magazyn danych useSyncExternalStore

To właśnie ta opcja sprawia, że eksperyment wydaje się najbardziej perswazyjny. Pełna implementacja, włączając magazyn danych, mieści się w 58 liniach kodu, dodaje 0,5 KB, idealnie odpowiada kolumnie renderowania w Redux i nie wymaga żadnych zależności do audytu ani aktualizacji. Funkcja useSyncExternalStore to narzędzie dostarczane przez sam React do bezpiecznego podłączania się do zewnętrznych magazynów danych, nawet podczas równoczesnego renderowania. Dla autorów bibliotek lub zespołów preferujących minimalne zależności jest to wystarczające. Wadą jest to, że to ty jesteś odpowiedzialny za kod: narzędzia deweloperskie, middleware oraz mechanizmy przechowywania musisz sam stworzyć, jeśli ich potrzebujesz.

Valtio do aktualizacji o dużym natężeniu

Grupowanie mikrozadań w Valtio było mierzalne, rzeczywiste i unikalne wśród pozostałych rozwiązań, a 2,7 KB to rozsądna cena za to. Zapisanie bezpośredniej modyfikacji w stylu state.apples++ i zobaczenie aktualizacji dokładnie tych właściwych komponentów wydaje się niemal zbyt wygodne, ale liczby renderów potwierdzają, że to zachowanie jest autentyczne.

Dlaczego pozostałe przegrywają w tym konkursie, mimo że nie są złe

Kształt testu ma znaczenie i faworyzuje mały, prosty stan. Pozostałe opcje mają zalety, których ten framework nie może wykorzystać:

  • 10,7 KB zajmowanego przez Redux Toolkit miejsca jest rekompensowane przez narzędzia deweloperskie, możliwość debugowania w czasie rzeczywistym, middleware oraz konwencje sprawdzające się w dużych organizacjach. Przy pięćdziesięciu programistach pracujących nad jednym kodem te konwencje stanowią prawdziwy produkt.
  • Model obserwowalny MobX jest najskuteczniejszy w kodzie domenowym bogatym w klasy, czego ta aplikacja nie posiada.
  • Graf atomowy Jotai wyróżnia się, gdy stan pochodny staje się złożony i ściśle powiązany, a pojedyncza suma pochodna ledwo go dotyka.
  • Kontekst pozostaje odpowiednim narzędziem dla wartości, które rzadko się zmieniają, takich jak temat, lokalizacja czy sesja. Ironią jest to, że kolumna tematu okazała się w tym przypadku najgorsza, ponieważ stan koszyka dzielił ten sam dostawcę.
  • Stan podniesiony nadal jest właściwy dla stanów, które nie są udostępniane. Przegrał jedynie dlatego, że to konkurs dotyczy konkretnie udostępniania.
  • Zasada projektowa, która nie zależy od gustu

    Każda z implementacji tutaj tworzyła swój magazyn wewnątrz komponentu. Magazyn zdefiniowany na poziomie modułu przetrwa rozłączenie komponentu, dzięki czemu koszyk zakupów pozostaje aktywny po wylogowaniu i jest dostępny dla następnej osoby logującej się na tym samym urządzeniu. Sprawia to również, że stan przecieka pomiędzy testami oraz pomiędzy żądaniami podczas renderowania na serwerze. Jeśli masz wybrać tylko jedną zasadę przeglądu kodu z tej porównania, niech to będzie ta: ograniczaj zasięg magazynów do drzewa komponentów, zazwyczaj tworząc je w providerze, chyba że celowo chcesz, aby miały globalny czas trwania.

    Uruchomienie tego samego konkursu w swojej aplikacji

    Możesz odtworzyć te testy dla własnego stanu w ciągu popołudnia:

    1. Wybierz najbardziej dyskutowany element stanu współdzielonego w swojej aplikacji i stwórz wokół niego strukturę składającą się z czterech komponentów: dwa komponenty do zapisywania danych, jeden do odczytywania wartości pochodnych oraz jeden komponent pasywny.
    2. Dodaj licznik renderowania do każdego komponentu. Obiekt na poziomie modułu w połączeniu z jedną wywołaniem track() w ciele każdego komponentu zajmuje około dziesięciu linii kodu. Liczba renderowań dokonywanych przez „obserwatora” stanowi Twoją opłatę za użycie Context – jest mierzona, a nie domyślana.
    3. Zmierz każdy kandydat za pomocą esbuild --bundle --minify, wskazując React jako bibliotekę zewnętrzną. Proces ten trwa kilka sekund i dodaje kolumnę z ilością kilobajtów do analizy.
    4. Jeśli korzystasz z Context, a „obserwator” ponownie renderuje komponenty, podziel kontekst albo przenieś się na magazyn danych oparty na selektorach, a następnie ponownie uruchom licznik i umieść liczby przed i po w swoim pull requestu.
    5. Powtórz całe ćwiczenie, gdy React Compiler stanie się częścią procesu budowania aplikacji, ponieważ automatyczna memoizacja może znacznie zmienić wyniki w kolumnie z danymi o stanach przenoszonych między komponentami.

    Pytania otwarte

    Dwa wątki pozostają niewiązane. Ten mniejszy jest przyczyną dodatkowego renderowania w Jotai. Ten większy to React Compiler. Wiersz z użyciem lifted-state pokazuje wartości 6/6/6/6 dokładnie dlatego, że nic w nim nie jest zapisywane w pamięci, a memoizacja to właśnie to, co kompilator automatyzuje. Czy uda się dostosować ten wiersz do magazynów opartych na selektorach w prawdziwym kodzie produkcyjnym, a nie tylko w demonstracji, to oczywisty następny eksperyment.

    Co dają liczniki renderowania i rozmiary plików

    • W małej skali różnice w standardowych elementach kodu pomiędzy bibliotekami do zarządzania stanem są zaniedbywalne; to zachowanie renderowania i rozmiar plików faktycznie się różnią.
    • Jeden Context przechowujący zmieniający się stan powoduje ponowne renderowanie wszystkich elementów, które go używają, włączając komponenty, które nigdy nie odczytują zmienionej wartości.
    • Magazyny oparte na selektorach, czy to Redux Toolkit, Zustand, MobX, czy ręcznie napisany magazyn useSyncExternalStore, opierają się na tym samym efektywnym wzorcu renderowania.
    • Zustand oraz ręcznie napisany magazyn zapewniają takie zachowanie przy rozmiarze poniżej kilobajtu; większe biblioteki osiągają swój rozmiar dzięki narzędziom i konwencjom, a nie procesowi renderowania.
    • Metoda grupowania zadań w Valtio jest przydatna przy nagłych strumieniach aktualizacji, a nie przy zwykłym klikaniu.
    • Twórz magazyny wewnątrz drzewa komponentów, aby stan nie przetrwał sesji, która go stworzyła.

    Literatura pokrewna