Strona główna / Artykuły / Pomiar menedżerów stanu React: porównanie liczby renderowań i kosztu pakietu

Pomiar menedżerów stanu React: porównanie liczby renderowań i kosztu pakietu

Aplikacja do zarządzania koszykiem skonstruowana przy użyciu ośmiu wzorców stanu React, sprawdzona pod kątem marnotrawstwa renderowań i rozmiaru spakowanego pliku, oraz to, co pokazują liczby na temat Zustand, Valtio i Context.

2227 słów

Kwestia wyboru odpowiedniego menedżera stanu w React pojawia się ciągle na nowo, a dyskusje zazwyczaj opierają się na opinii, a nie na pomiarach. Bardziej przydatnym podejściem jest stworzenie tej samej małej funkcjonalności za pomocą każdego z powszechnie stosowanych wzorców, utrzymanie spójnego zachowania za pomocą wspólnego zestawu testów oraz sprawdzenie tego, co faktycznie się różni: jak często komponenty są ponownie renderowane i ile kilobajtów dodaje każda z opcji. Ten artykuł przedstawia takie doświadczenie z użyciem ośmiu wzorców, wyjaśnia, dlaczego wyniki są takie, jakie są, oraz dostarcza powtarzalnego sposobu na przeprowadzenie tego samego testowania własnego menedżera stanu.

Jak przygotowano to doświadczenie

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ę poszczególnych komponentów. Oddzielnie esbuild mierzy, ile każdy z tych rozwiązań dodaje do spakowanego i skompresowanego pliku, z wyłączeniem Reacta, ponieważ każda opcja generuje ten sam koszt.

Osiem kandydatów to:

  • Ulepszona wersja useState przekazywana przez propsy
  • Context połączony z useReducer
  • Redux Toolkit
  • Zustand
  • Jotai
  • Valtio
  • MobX
  • Ręcznie napisany magazyn danych oparty na useSyncExternalStore, bez żadnych zależności

Wszystkie osiem rozwiązań spełnia wspólne wymagania 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 wymieniono wersje bibliotek 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 rozwiązania wynikają z błędów występujących w rzeczywistych aplikacjach, a nie z zasad stosowanych podczas tworzenia benchmarków.

Po pierwsze, każda implementacja tworzy swój magazyn wewnątrz komponentu, a nie na poziomie modułu. Dzięki temu każda uruchomiona aplikacja zaczyna od zupełnie czystego stanu, 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 jej renderowanie podczas testu stanowi zmarnowany wysiłek, za który można winić tylko wzorzec stanu.

Co celowo nie jest objęte zakresem

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

Niespodzianka numer jeden: rozmiar kodu jest niemal identyczny

Zanim przejdziemy do ciekawych liczb, warto zwrócić uwagę na jedną nudną. Osiem różnych implementacji składa się z od 39 do 58 linii kodu. Najbardziej rozbudowana opcja, Redux Toolkit z 58 liniami, jest 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ł wyrenderowany podczas pięciu kliknięć, łącznie z początkowym załadowaniem:

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 danych wynikają trzy odrębne historie.

Lifted state i Context sprawiają, że wszystko jest ponownie renderowane

Dzięki przeniesieniu useState i użyciu jednego Contextu każdy komponent renderuje się przy każdym kliknięciu. Odznaka tematu była renderowana sześć razy, mimo że jej wartość nigdy się nie zmieniła, a przycisk bananowy renderował 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. useContext subskrybuje komponent na całą wartość contextu, więc gdy tylko ta wartość zmienia swoją tożsamość, każdy komponent korzystający z niej jest renderowany. Umieszczenie całego stanu w jednym contextie jest w praktyce równoznaczne z przeniesionym stanem z dodatkową warstwą.

Zwykłym rozwiązaniem jest podział stanu między kilka contextów lub memoizacja komponentów korzystających z nich, ale tego nie mierzono tutaj. Przykład odzwierciedla 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 dają dokładnie ten sam wynik: każdy komponent jest renderowany raz podczas inicializacji, 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 zobaczyć, jak działa ta selekcja oraz sprawdzanie równości w jednej z tych bibliotek, zapoznaj się z tym, jak React Redux decyduje o ponownym renderowaniu.

Niepokojąco niskie liczby Valtio

Kolumna Valtio na pierwszy rzut oka przypomina uszkodzony licznik: dwa rendery 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 jeden render na komponent, a ostateczne wartości pozostają prawidłowe.

Istnieje ważna zastrzeżenie. Osoba klikająca z normalną prędkością powoduje jeden render 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 z wiadomościami WebSocket, danymi strumieniowymi lub zdarzeniami przeciągania. Jeśli twoja aplikacja ma taki profil, ta kolumna wymaga uważnej obserwacji.

Spójny dodatkowy render 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 montażu z Providerem sklepu, 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.

Ocena wielkości pliku

To pokazuje, ile każdy wzorzec dodaje do spakowanego 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 i MobX razem zajmują 23,9 KB, podczas gdy pozostałe sześć wzorców razem wynosi 9,2 KB.

Ciekawe jest to, jak ta lista współdziała z tablicą wyników 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 takie same wyniki w rozmiarze 10,7 KB, a MobX – 13,2 KB. To niekoniecznie oznacza ich wadę; to po prostu cena, a cena ma sens dopiero 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łą precyzję renderowania przy rozmiarze 0,7 KB i zaledwie 54 liniach kodu; jego API prawie nie wymaga wyjaśnień dla nowego współpracownika – jest to hook przyjmujący selektor. W tym teście jego zachowanie podczas wykonywania byłoby porównywalne z Redux, ale przy rozmiarze około jednej piętnastej.

To rozwiązanie zależy od jednej zasady: zawsze należy wybrać odpowiedni selektor. Wywołanie typu useCart(s => s) subskrybuje komponent do całego sklepu danych, co powoduje ten sam problem z kontekstem. Efektywność pochodzi od pisania wąskich selektorów, a nie od nazwy biblioteki. Jeśli selektor zwraca nowy obiekt lub tablicę przy każdym wywołaniu, potrzebny 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 sklep 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 zaledwie 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 dostarczone przez sam React do bezpiecznego podłączania się do zewnętrznych magazynów danych, nawet przy jednoczesnym renderowaniu. Dla autorów bibliotek lub zespołów preferujących minimalne zależności jest to wystarczające. Wadą jest to, że odpowiedzialność za kod spoczywa na użytkowniku – jeśli potrzebujesz narzędzi takich jak devtools, middleware czy mechanizmy persistencji, musisz je sam stworzyć.

Valtio do aktualizacji o dużym natężeniu

Budowanie grup mikrozadań przez Valtio było mierzalne, rzeczywiste i unikalne wśród pozostałych konkurentów, 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 renderowania potwierdzają, że to zachowanie jest autentyczne.

Dlaczego pozostali przegrywają w tym konkursie, mimo że nie są gorsi

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 ten aplikacja nie posiada.
  • Graf atomowy Jotai wyróżnia się, gdy stan pochodny staje się złożony i wzajemnie powiązany, a pojedynczy łączny wynik 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 z tematem 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 przenika się 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, zwykle poprzez tworzenie ich w providerze, chyba że celowo chcesz globalnego czasu trwania.

    Rozpoczęcie 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.
  • Dodaj licznik renderowania do każdego komponentu. Obiekt na poziomie modułu wraz 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.
  • 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.
  • 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.
  • 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 stanie.
  • Nierozwiązane pytania

    Dwa wątki pozostają niewiązane. Ten mniejszy jest przyczyną dodatkowego renderowania w Jotai, a 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.

    Główne wnioski

    • W małej skali różnice w standardowych elementach kodu pomiędzy bibliotekami do zarządzania stanem są zaniedbywalne; to zachowanie renderowania i rozmiar pliku stanowią rzeczywiste różnice.
    • Jeden Context przechowujący zmieniający się stan powoduje ponowne renderowanie wszystkich komponentów, w tym tych, które nigdy nie odczytują zmienionej wartości.
    • Magazyny oparte na selektorach, czy to Redux Toolkit, Zustand, MobX, czy ręcznie napisany magazyn useSyncExternalStore, dążą do tego samego efektywnego wzorca renderowania.
  • Zustand oraz ręcznie pisane sklepy zapewniają takie zachowanie przy rozmiarze poniżej kilobajtu; większe biblioteki uzyskują swój rozmiar dzięki narzędziom i konwencjom, a nie procesowi renderowania.
  • Metoda grupowania zadań w Valtio jest korzystna przy nagłych strumieniach aktualizacji, a nie przy zwykłym klikaniu.
  • Twórz sklepy wewnątrz drzewa komponentów, aby stan nie przetrwał sesji, która go stworzyła.