Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik10 min czytania

Zustand 5, stan bez ceremonii i pułapki selektorów

Zustand trzyma stan aplikacji bez dostawców i akcji. Wersja 5, selektory zwracające nowe referencje i migracja z czwórki bez pętli renderowania.

Zustand 5, stan bez ceremonii i pułapki selektorów

Zustand trzyma stan wspólny dla aplikacji w zwykłym obiekcie, do którego sięgasz z komponentu jednym wywołaniem. Nie ma dostawcy owijającego drzewo, nie ma typów akcji, nie ma reduktorów. Bieżąca wersja to 5.0.14, licencja MIT.

Sedno tej biblioteki mieści się w kilkunastu linijkach i to jest jej główny argument. Cała reszta tekstu dotyczy dwóch rzeczy, które przy niej sprawiają realne kłopoty: doboru selektorów oraz zmian, jakie przyniosła wersja piąta.

Podstawowy układ

Code
TypeScript
import { create } from 'zustand'

type Koszyk = {
  pozycje: Pozycja[]
  dodaj: (p: Pozycja) => void
  wyczysc: () => void
}

export const useKoszyk = create<Koszyk>((set) => ({
  pozycje: [],
  dodaj: (p) => set((s) => ({ pozycje: [...s.pozycje, p] })),
  wyczysc: () => set({ pozycje: [] }),
}))

W komponencie sięgasz po to bezpośrednio:

Code
TypeScript
const liczba = useKoszyk((s) => s.pozycje.length)
const dodaj = useKoszyk((s) => s.dodaj)

Dwie rzeczy warto tu zauważyć od razu.

Pierwsza: funkcje zmieniające stan mieszkają w tym samym obiekcie co dane. Nie ma osobnej warstwy akcji ani miejsca, w którym dopisuje się typ zdarzenia. To jest cała ceremonia, której ta biblioteka nie wymaga, i główny powód, dla którego ludzie po nią sięgają.

Druga jest subtelniejsza i ważniejsza: set scala płytko. Zwracasz wyłącznie zmienione pola, a reszta zostaje. Przy stanie zagnieżdżonym to znaczy, że podmiana obiektu wewnętrznego wymaga rozwinięcia go ręcznie, bo scalanie nie schodzi głębiej niż jeden poziom.

Selektory, czyli miejsce, w którym rodzą się kłopoty

To jest jedyna rzecz, którą trzeba zrozumieć dobrze, żeby ta biblioteka działała bez niespodzianek.

Selektor decyduje, kiedy komponent się przerenderuje. Sięgnięcie po cały stan sprawia, że komponent reaguje na każdą zmianę czegokolwiek, nawet pola, którego nie używa. Sięgnięcie po jedno pole ogranicza to do zmian tego pola.

Code
TypeScript
const stan = useKoszyk()

const liczba = useKoszyk((s) => s.pozycje.length)

Pierwsza linia to najczęstszy powód, dla którego aplikacja renderuje się częściej, niż powinna, i najłatwiejszy do naprawienia.

Problem poważniejszy pojawia się, gdy selektor zwraca coś nowego przy każdym wywołaniu.

Code
TypeScript
const { pozycje, dodaj } = useKoszyk((s) => ({
  pozycje: s.pozycje,
  dodaj: s.dodaj,
}))

Ten zapis wygląda naturalnie i jest pułapką. Selektor tworzy nowy obiekt za każdym razem, więc porównanie referencji zawsze wypada negatywnie, komponent renderuje się ponownie, selektor tworzy kolejny obiekt i tak w kółko. W wersji piątej kończy się to pętlą, bo biblioteka opiera się teraz wprost na mechanizmie Reacta wymagającym stabilnych wyników.

Rozwiązania są dwa. Można sięgnąć po każde pole osobno, co jest najprostsze i zwykle wystarcza. Można też użyć pomocnika porównującego płytko, gdy naprawdę potrzebujesz kilku pól naraz.

Code
TypeScript
import { useShallow } from 'zustand/shallow'

const { pozycje, dodaj } = useKoszyk(
  useShallow((s) => ({ pozycje: s.pozycje, dodaj: s.dodaj }))
)

Ta sama zasada dotyczy selektorów filtrujących. Wyrażenie zwracające przefiltrowaną tablicę tworzy nową tablicę przy każdym renderze, więc wymaga tego samego traktowania albo przeniesienia filtrowania poza selektor.

Co zmieniła wersja piąta

Migracja z poprzedniej wersji jest krótka, o ile wiesz, na co patrzeć.

Zniknęły domyślne eksporty, więc importy trzeba zamienić na nazwane. To zmiana widoczna, bo kompilator zgłosi ją od razu.

Zniknęło wsparcie dla Reacta starszego niż osiemnasty. Dzięki temu biblioteka korzysta wprost z wbudowanego mechanizmu subskrypcji zamiast pakietu zastępczego, co upraszcza zależności i skraca kod.

Funkcja tworząca magazyn przestała przyjmować własną funkcję porównującą. Jeśli używałeś tej możliwości, masz dwie drogi: wariant tworzący magazyn z funkcją porównującą, dostępny pod osobną ścieżką importu, albo przejście na pomocnika porównującego płytko przy selektorze. Druga droga jest zwykle lepsza, bo trzyma decyzję przy miejscu użycia.

Najważniejsza jest jednak zmiana zachowania, a nie interfejsu. Selektor zwracający nową referencję powoduje teraz pętlę zamiast po prostu nadmiarowego renderowania. Kod, który wcześniej działał niewydajnie, może teraz przestać działać zupełnie.

Praktyczna rada dotycząca kolejności: przed przejściem na piątkę zaktualizuj do najnowszej czwórki. Ta wersja wypisuje ostrzeżenia o wszystkim, co zniknie, bez psucia aplikacji, więc dostajesz listę miejsc do poprawienia zanim cokolwiek się zepsuje.

Typowanie i kształt magazynu

Typ magazynu opisuje się raz i biblioteka wyprowadza z niego resztę, natomiast jest tu jeden szczegół, o który potyka się większość osób.

Parametr typu podaje się przy wywołaniu tworzącym magazyn, a nie przy wyniku. Zapis wymaga podwójnego wywołania w wariancie z warstwami, co wygląda dziwnie i ma powód: pozwala kompilatorowi wyprowadzić typ przez wszystkie nałożone warstwy zamiast gubić go po drodze.

Warto też rozdzielić w typie dane od funkcji. Struktura, w której pola opisujące stan leżą osobno od funkcji go zmieniających, ułatwia utrwalanie, bo wskazanie, co zapisać, staje się oczywiste. Mieszanie obu w jednym płaskim obiekcie działa i szybko przestaje być czytelne przy dwudziestu polach.

Osobna sprawa to funkcje pobierające wyprowadzone wartości. Kuszące jest umieszczenie ich w magazynie obok danych, natomiast wtedy stają się częścią stanu i biorą udział w porównaniach przy każdej zmianie. Lepszym miejscem jest zwykła funkcja poza magazynem, przyjmująca stan jako argument, albo selektor zapisany osobno i używany w wielu komponentach.

Selektory zapisane jako nazwane stałe mają jeszcze jedną zaletę. Ta sama referencja funkcji używana w kilku miejscach jest łatwiejsza do znalezienia przy refaktoryzacji niż wyrażenie wpisane wprost w dwudziestu komponentach, a przy okazji wymusza przemyślenie, co dokładnie ten komponent potrzebuje.

Testowanie i praca poza komponentem

Magazyn jest zwykłym obiektem, więc testuje się go bez środowiska renderującego, i to jest zaleta, którą warto wykorzystać.

Logika zmieniająca stan da się sprawdzić wywołaniem funkcji i odczytaniem wyniku, bez montowania czegokolwiek. To jest znacznie szybsze niż testowanie przez interfejs i wychwytuje inne błędy, więc oba rodzaje testów mają sens obok siebie.

Jedna rzecz wymaga uwagi: magazyn utworzony na poziomie modułu jest wspólny dla wszystkich testów w pliku. Stan po jednym teście wchodzi do następnego i powstają zależności między testami, które ujawniają się dopiero po zmianie ich kolejności. Rozwiązaniem jest przywracanie stanu początkowego przed każdym testem albo tworzenie magazynu w funkcji fabrykującej.

Poza testami ten sam mechanizm przydaje się w kodzie działającym poza drzewem komponentów: w obsłudze zdarzenia globalnego, w funkcji przetwarzającej wiadomość z gniazda albo w kodzie reagującym na zmianę adresu. Odczyt i zapis działają tam bez żadnego kontekstu, a subskrypcja pozwala reagować na zmiany bez renderowania czegokolwiek.

Zustand kontra alternatywy

RozwiązanieZakresCeremoniaKiedy wybrać
ZustandStan klienckiMinimalnaStan wspólny dla aplikacji
TanStack QueryDane z serweraŚredniaWszystko, co przychodzi z zaplecza
Kontekst ReactaStan klienckiMałaRzadko zmieniana wartość, np. motyw
Redux z zestawem narzędziStan klienckiDużaDuży zespół, potrzeba ścisłych reguł

Drugi wiersz to najczęstsze nieporozumienie wokół tej biblioteki i warto je rozstrzygnąć wprost. Dane pochodzące z serwera nie są stanem aplikacji. Wymagają buforowania, odświeżania, ponawiania przy błędzie sieci i unieważniania po zapisie, a przechowywanie ich w magazynie klienckim oznacza ręczne odtwarzanie tych mechanizmów.

Właściwy układ to jedno i drugie obok siebie: biblioteka zapytań do danych z zaplecza, ta biblioteka do stanu, który istnieje wyłącznie w przeglądarce. Otwarte menu, wybrana zakładka, zawartość koszyka przed wysłaniem, stan kreatora rozłożonego na kilka kroków.

Trzeci wiersz też warto rozważyć. Kontekst Reacta jest wbudowany i wystarcza przy wartościach zmieniających się rzadko. Kłopot zaczyna się, gdy wartość zmienia się często, bo wtedy renderuje się całe poddrzewo, i to jest właśnie moment, w którym sięga się po osobny magazyn.

Dodatki, które warto znać

Biblioteka jest mała, a rozszerza się przez warstwy nakładane na magazyn.

Zapisywanie stanu do pamięci przeglądarki załatwia jedna warstwa. Przydaje się przy koszyku, ustawieniach interfejsu i częściowo wypełnionych formularzach.

Przy tej możliwości są dwie pułapki. Pierwsza to zapisywanie całego stanu razem z polami, które zapisane być nie powinny, na przykład tymczasowymi znacznikami ładowania. Rozwiązuje to wskazanie, co dokładnie utrwalać. Druga to zmiana kształtu stanu po wydaniu nowej wersji aplikacji: użytkownik ma w pamięci przeglądarki stary układ, a kod oczekuje nowego. Bez wersjonowania i funkcji przenoszącej stan aplikacja wywala się u osób, które ją wcześniej odwiedziły, i nie odtworzysz tego na świeżej przeglądarce.

Podłączenie do narzędzi deweloperskich Reduxa daje wgląd w historię zmian i podróż w czasie. Warto włączać je wyłącznie poza produkcją, bo dokłada rozmiar i wystawia stan aplikacji.

Warstwa pozwalająca pisać zmiany tak, jakby stan był modyfikowalny, jest wygodna przy głęboko zagnieżdżonych strukturach. Zanim po nią sięgniesz, warto zadać pytanie, czy stan naprawdę musi być tak zagnieżdżony, bo płaska struktura z identyfikatorami rozwiązuje ten problem bez dodatkowej zależności.

Wzorce, które się sprawdzają

Kilka rzeczy oszczędza kłopotów przy dłuższej pracy.

Trzymaj funkcje zmieniające stan w magazynie, a nie w komponentach. Komponent wywołujący set bezpośrednio rozprasza logikę po interfejsie i uniemożliwia jej przetestowanie osobno.

Dziel magazyny według obszaru zamiast budować jeden na całą aplikację. Osobny magazyn dla koszyka, osobny dla ustawień interfejsu. Mniejsze magazyny znaczą mniej powiązań i mniej powodów do renderowania.

Wyprowadzone wartości licz w selektorze, nie trzymaj ich w stanie. Suma pozycji w koszyku wyliczona z tablicy nie może się z nią rozjechać, a suma zapisana obok tablicy owszem.

Przy dostępie do magazynu poza komponentem, na przykład w obsłudze zdarzenia albo w funkcji pomocniczej, sięgnij po metodę odczytu na samym magazynie zamiast wywoływać punkt zaczepienia. Wywołanie go poza komponentem jest błędem, którego kompilator nie wyłapie.

Ostatnia rzecz dotyczy renderowania po stronie serwera. Magazyn utworzony na poziomie modułu jest współdzielony między żądaniami, więc w Next.js stan jednego użytkownika może wyciec do drugiego. Przy stanie zależnym od użytkownika magazyn trzeba tworzyć na żądanie i podawać przez dostawcę, co jest jedynym przypadkiem, w którym ta biblioteka wymaga owijania drzewa.

Typowe błędy

Pierwszy to selektor zwracający nowy obiekt albo tablicę przy każdym wywołaniu. W wersji piątej kończy się to pętlą renderowania, a nie tylko spadkiem wydajności.

Drugi to sięganie po cały stan zamiast po pojedyncze pola. Komponent reaguje wtedy na każdą zmianę, także tę, która go nie dotyczy.

Trzeci to trzymanie danych z serwera w tym magazynie. Powstaje wtedy ręczna warstwa odtwarzająca buforowanie i odświeżanie, czyli to, co biblioteka zapytań robi sama.

Czwarty to utrwalanie całego stanu bez wskazania, co dokładnie zapisywać. Znaczniki ładowania i dane tymczasowe wracają wtedy po odświeżeniu strony i psują pierwsze renderowanie.

Piąty to brak wersjonowania utrwalonego stanu. Po zmianie kształtu aplikacja wywala się u osób, które odwiedziły ją wcześniej, a na świeżej przeglądarce działa bez zarzutu.

Szósty to magazyn utworzony na poziomie modułu w aplikacji renderowanej na serwerze. Stan jest wtedy współdzielony między żądaniami i dane jednego użytkownika trafiają do drugiego.

Siódmy to wywoływanie punktu zaczepienia poza komponentem, na przykład w obsłudze zdarzenia globalnego. Do tego służy metoda odczytu na samym magazynie, a błędne użycie nie zgłosi się jako błąd kompilacji.

FAQ

Czym Zustand różni się od Reduxa?

Ilością ceremonii. Nie ma tu typów akcji, reduktorów ani dostawcy owijającego drzewo. Funkcje zmieniające stan mieszkają obok danych, a komponent sięga po magazyn bezpośrednio. Redux daje w zamian ściślejsze reguły, co bywa zaletą w dużym zespole.

Czy Zustand zastępuje TanStack Query?

Nie. To narzędzie do stanu istniejącego wyłącznie w przeglądarce, a TanStack Query obsługuje dane pochodzące z serwera razem z buforowaniem i odświeżaniem. Typowa aplikacja używa obu do różnych rzeczy.

Dlaczego mój komponent wpada w pętlę renderowania?

Najczęściej dlatego, że selektor zwraca nowy obiekt albo tablicę przy każdym wywołaniu. Sięgnij po pola osobno albo owiń selektor pomocnikiem porównującym płytko, dostępnym pod osobną ścieżką importu.

Jak przejść z wersji czwartej na piątą?

Najpierw zaktualizuj do najnowszej czwórki, żeby zobaczyć ostrzeżenia bez psucia aplikacji. Potem zamień domyślne importy na nazwane, podnieś Reacta do wersji osiemnastej lub nowszej i przejrzyj selektory zwracające nowe referencje.

Czy Zustand działa z renderowaniem po stronie serwera?

Tak, pod warunkiem że magazyn nie jest tworzony na poziomie modułu przy stanie zależnym od użytkownika. Wtedy trzeba go tworzyć na żądanie i podawać przez dostawcę, inaczej stan jest współdzielony między żądaniami.

Dokumentacja stoi na stronie projektu, a opis przejścia na piątą wersję w przewodniku migracji.