Dlaczego Redux i Zustand są popularne wśród wielu programistów, a nie czytelników?
Liczba czytelników nie jest wystarczającym powodem do utworzenia sklepu klienta. W przypadku wielu autorów korzystających z niezmienników — takich jak koszyk zakupowy — to reduktory, ponowne wykonywanie operacji oraz testy bezpośrednie okazują się przydatne.
Cztery powszechne uzasadnienia dla używania Redux, Zustand lub podobnych bibliotek zostały już rozebrane: przenoszenie danych przez propy, aktualizacje po stronie klienta, automatyczne rozprzestrzenianie się zmian do wszystkich subskrybentów oraz Context jako bezpieczniejszy standard. React już obsługuje te przypadki bez dodatkowego sklepu danych.
Żaden z tych argumentów nie poruszył pytania, które faktycznie decyduje o wyborze biblioteki. Nie chodzi o to, ile komponentów *czyta* daną wartość, lecz o to, ilu niepowiązanych miejsc może ją *zmieniać* oraz czy te zmiany muszą być później spójne ze sobą.
Przełącznik tematu ma jednego autora zmian – jeden element sterujący, jedno wywołanie funkcji setTheme. Dlatego biblioteka jest tam niepotrzebna, bez względu na to, ile ekranów korzysta z jej wyniku. Koszyk zakupowy ma zupełnie inny charakter: wiele elementów może wpływać na dane w różnych kierunkach, więc wymaga innego przykładu.
Jeden autor zmian versus wielu
W karcie produktu koszyk jest pokazywany jako „Dodaj do koszyka”, w szufladzie jako przycisk do zmiany ilości, jako link do usunięcia, jako pole z kodem rabatowym, które przelicza łączną kwotę, oraz jako przycisk do usunięcia wszystkiego. Pięć plików, pięć miejsc mutacji – każde z nich może zmienić ten sam stan, przy czym żaden z nich nie wie o pozostałych czterech.
Porównaj to z flagą tematu: jeden przycisk, jeden „pisarz”. Każdy odczytujący po prostu wyświetla ostatnią wartość. Nie ma żadnej koordynacji, ponieważ tylko jedna „ręka” obraca kółko.
Koszyk przechowuje niezmienniki – a jest ich trzy. Niezmiennik to fakt, który musi pozostać prawdziwy po każdej operacji. W przypadku tego koszyka: łączna kwota zawsze równa się sumie ceny pozycji pomnożonej przez ilość, minus aktywny rabat; ilość nigdy nie staje się ujemna; dwie pozycje nigdy nie mają tego samego SKU – dodanie kolejnej sztuki istniejącego produktu zwiększa ilość, zamiast tworzyć duplikatową pozycję.
Pięciu autorów, trzy fakty, które każdy autor musi zachować. To właśnie problem, który ma rozwiązać Redux, Zustand lub MobX, i nie ma on nic wspólnego z tym, ilu komponentów jedynie *czyta* koszyk.
Co idzie nie tak, gdy pięciu niezależnych autorów edytuje te same trzy fakty bez żadnych zasad ich kontrolowania?
Co się psuje bez uporządkowanego systemu
Załóżmy, że trzy z tych pięciu miejsc mutacji, każde napisane w stylu wybranym przez właściciela pliku, bezpośrednio modyfikują obiekt koszyka:
// components/AddToCartButton.jsx
function addItem(item) {
cart.items.push(item);
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
const item = cart.items.find(i => i.sku === sku);
item.quantity = newQty;
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
cart.discount = getDiscountFor(code);
}
getDiscountFor to prosta funkcja wyszukiwania: podaje się kod, a w wyniku otrzymuje się numer zniżki. AddToCartButton.jsx oraz QuantityStepper.jsx obie ponownie obliczały wartość cart.total, natomiast CouponInput.jsx tego nie robiło. Niesprawiedliwość polegająca na tym, że „suma kosztów równa się sumie cen minus zniżka”, została naruszona, a system nie podjął żadnych działań — nikt nie był odpowiedzialny za tę zasadę. Była to konwencja, według której trzy pliki miały ją przestrzegać; jeden o tym zapomniał.
Reduktor eliminuje tę konieczność zaufania, wymuszając przeprowadzanie wszelkich modyfikacji przez jedną funkcję. Reduktor bierze aktualny stan oraz akcję (zwykły obiekt opisujący to, co się wydarzyło) i zwraca zupełnie nowy stan. Nigdy nie modyfikuje on starego obiektu na miejscu.
// store/cartReducer.js
function computeTotal(items, discount) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return subtotal * (1 - discount);
}
export function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const items = [...state.items, action.item];
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'CHANGE_QUANTITY': {
const items = state.items.map(i =>
i.sku === action.sku ? { ...i, quantity: action.qty } : i
);
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'APPLY_COUPON': {
const discount = getDiscountFor(action.code);
return { ...state, discount, total: computeTotal(state.items, discount) };
}
}
}
computeTotal jest wykonywany w każdej gałęzi switch — nie dlatego, że każdy autor o tym pamiętał, ale ponieważ użycie jednej funkcji i jednego switch sprawia, że pomijanie tego kroku jest niewygodne. Trzej wcześniej bezpośredni autorzy tekstu teraz opisują wydarzenia zamiast je realizować:
// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';
function AddToCartButton({ item }) {
const dispatch = useCartDispatch();
return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';
function QuantityStepper({ sku, qty }) {
const dispatch = useCartDispatch();
return (
<input
type="number"
value={qty}
onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
/>
);
}
export default QuantityStepper;
dispatch pochodzi z małego pliku CartContext.jsx, który łączy useReducer (wbudowany hook zwracający stan wraz z funkcją dispatch) z kontekstem Context, dzięki czemu każdy komponent może uzyskać dostęp do tej funkcji dispatch. Ani przycisk, ani element stepper już nie zapisują cart.total = .... Żaden z nich nie musi wiedzieć, że istnieje computeTotal.
Dane pozostają otwarte do odczytu. Komponenty mogą nadal odczytywać cart.total w celu wyświetlania cen, podsumowań koszyka lub komunikatów dotyczących niezapisanych elementów. Zapisy odbywają się wyłącznie jedną ścieżką: wysyła się akcję, a reduktor generuje następny stan. Ta zasada obowiązuje w tym kluczowym punkcie, a nie u tego, kto akurat ją wywołuje.
Należy być precyzyjnym: pojedyncza funkcja zapisu nie czyni reguły poprawną — sprawia jedynie, że jej egzekwowanie jest spójne. Gdyby sama funkcja computeTotal zapomniała o zniżce, każda ścieżka zapisu dawałaby za każdym razem ten sam błędny wynik. To i tak lepsze niż wersja rozproszona: błąd jednolity łatwiej jest zlokalizować. Błąd, który pojawia się tylko wtedy, gdy jakiś plik zapomina o danej linijce, jest znacznie trudniejszy do znalezienia, ponieważ poprawne i błędne ścieżki wyglądają identycznie, dopóki nie napotka się brakującego przypadku.
Gdy ścieżka kuponu zapomina o ponownym obliczeniu, interfejs może nadal wyglądać poprawnie, dopóki nie nastąpi późniejsza zmiana ilości wymagająca ponownego obliczenia — albo dopóki koszyk nie pokaże łącznej kwoty, która już nie odpowiada pozycjom w koszyku. To przerywane zachowanie jest dokładnie powodem, dla którego konwencje zawodzą: uszkodzona ścieżka występuje na tyle rzadko, by umknąć przypadkowemu kliknięciu, a na tyle często, by powodować problemy.
Centralizacja reguły nie eliminuje potrzeby starannej logiki funkcji computeTotal. Zapewnia natomiast, że każda ścieżka generująca dane stosuje tę samą funkcję, dzięki czemu błędna formuła jest spójna i dlatego możliwa do wykrycia. Rozproszone aktualizacje ukrywają ten sam błąd pod hasłem „prawie działa”.
Jedna zdyscyplinowana ścieżka generowania danych ma wartość sama w sobie. Co jeszcze daje, gdy później coś się zepsuje?
Każda akcja staje się możliwością odtworzenia stanu
Dwa właściwości reduktora połączone ze sobą dają coś silniejszego niż zwykły plik logów.
Najpierw działania są serializowalne: zwykły JSON bez funkcji, instancji klas ani ukrytych odniesień — tylko { type: 'APPLY_COUPON', code: 'SAVE10' }. Po drugie, cartReducer jest czysty: identyczny stan w połączeniu z identycznym działaniem zawsze daje identyczny następny stan, bez żadnych zewnętrznych odczytów.
Wspólnie odtwarzanie sekwencji od tego samego początku zawsze prowadzi do tego samego końca. To właśnie ten determinizm jest wykorzystywany w Redux DevTools. Nie tylko wyświetla informację o tym, że coś się stało; przechowuje listę działań jako dane i ponownie oblicza dokładny zrzut stanu po kliknięciu na dowolny wybrany krok.
Weźmy wcześniejszy błąd – suma jest całkowicie błędna po zastosowaniu kuponu, a następnie usunięciu produktu. Gdy DevTools są otwarte, sesja wykazuje komendy ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Po wykonywaniu APPLY_COUPON suma jest poprawna; po REMOVE_ITEM już nie. Błąd ma określone źródło – gałąź REMOVE_ITEM – bez konieczności dodawania zapisów console.log czy ręcznego odtwarzania sesji użytkownika.
Ta zaleta nie jest taka sama we wszystkich bibliotekach. Redux DevTools to dojrzała wersja oryginalna. Middleware devtools w Zustand łączy funkcję set() z tą samą rozszerzeniem, dzięki czemu aktualizacje pojawiają się jako nazwane akcje. MobX zazwyczaj modyfikuje obserwowalne wartości bezpośrednio za pośrednictwem proxyów, zamiast używać serializowalnych akcji, więc jego narzędzia kładą większy nacisk na grafy reakcji – pokazujące, które obliczenia zostały ponownie wykonywane i dlaczego – niż na pełny rejestr podróży w czasie.
Replay wymaga funkcji deterministycznej, która zawsze mapuje te same dane wejściowe na te same wyniki. Co jeszcze daje to poza rozszerzeniem przeglądarki?
Reducer to po prostu funkcja, którą można wywołać
cartReducer(startState, action) to zwykłe wywołanie: argumenty wchodzą, cały nowy stan wychodzi. Testowanie go nie wymaga przeglądarki, kliknięć ani renderowanego komponentu.
// store/cartReducer.test.js
import { cartReducer } from './cartReducer';
test('APPLY_COUPON recomputes total', () => {
const startState = {
items: [{ sku: 'A1', price: 20, quantity: 2 }],
discount: 0,
total: 40,
};
const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
expect(nextState.discount).toBe(0.10);
expect(nextState.total).toBe(36); // 40 minus 10 percent
});
Test kończy się w milisekundach bez użycia biblioteki do renderowania. Bezpośrednio wykrywa wcześniejszy błąd: jeśli APPLY_COUPON pominął wywołanie computeTotal, nextState.total nadal miałby wartość 40, a sprawdzenie zawiedłoby w tym błędnym przypadku, zamiast wskazywać na niejasną niezgodność interfejsu.
Taką samą logikę jak ustawiacz useState w obsłudze kliknięcia nie da się w ten sposób przetestować — a powodem nie jest JSX:
// hooks/useCoupon.js
import { useState } from 'react';
function useCoupon() {
const [discount, setDiscount] = useState(0);
const applyCoupon = code => setDiscount(getDiscountFor(code));
return { discount, applyCoupon };
}
export default useCoupon;
Wywołanie useCoupon() z zwykłego testu powoduje błąd. Hooki takie jak useState działają tylko podczas renderowania React (lub wewnątrz innego hooka wywołanego podczas renderowania) i są śledzone w odniesieniu do konkretnej instancji komponentu. Takie są zasady hooków: bezwarunkowe wywołania w tej samej kolejności przy każdym renderowaniu, ponieważ React porównuje stan hooków na podstawie kolejności wywołań, a nie nazwy. Jeśli pominąć hook przy niektórych renderowaniach, dochodzi do rozbieżności w śledzeniu stanu.
applyCoupon również nie zwraca przydatnego stanu w taki sposób, jak to robi cartReducer. Funkcja setDiscount zwraca undefined. Planuje ponowną renderizację; nowa wartość pojawia się dopiero podczas następnego uruchomienia ciała komponentu. Nie ma wartości zwracanej do sprawdzenia — mechanizm polega na „prośbie o ponowną renderizację w React”, a nie na „obliczeniu i zwróceniu wartości”.
Testowanie tego oznacza renderowanie czegoś i sprawdzenie tego, co pojawiło się na ekranie:
// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';
test('applying coupon updates the displayed total', () => {
render(<CartSummary />);
fireEvent.click(screen.getByText('Apply SAVE10'));
expect(screen.getByTestId('total')).toHaveTextContent('36');
});
To samo zagadnienie poddawane testowi — łączna kwota po zastosowaniu kuponu — ale sprawdzenie dotyczy tekstu wyświetlonego po pełnej renderizacji i symulowanym kliknięciu.
Srednią drogą jest renderHook z React Testing Library: można użyć tego hooka bez JSX ani kliknięć, a następnie sprawdzić wartość result.current. Nadal wymaga on renderera testowego React pod funkcją act, który przetwarza aktualizacje i efekty przed sprawdzaniem założeń. Struktura wspomagająca pozostaje, ponieważ stan hooka nadal znajduje się w zainstalowanej (nawet minimalnej) instancji. cartReducer nie wymagał niczego z tego; nigdy nie był powiązany z żadnym komponentem.
Testowalność dotyczy stopnia powiązań między elementami. Jak wygląda ta sama koncepcja przy określaniu, gdzie kończy się jeden magazyn danych, a zaczyna drugi?
Gdzie faktycznie przebiega granica
Inwarianty początkowe odpowiadają również na inne pytanie: gdzie jeden magazyn danych powinien się zakończyć, a następny rozpocząć.
Elementy koszyka items, discount i total należą do siebie, ponieważ są ze sobą powiązane — zmiana jednego z nich może unieważnić informacje zależne od pozostałych. theme nie powinien znajdować się w tym reduktorze: nic związane z theme nie określa wartości cart.total, a nic związane z koszykiem nie określa theme. Są to niezależne stany, które jedynie współistnieją w aplikacji.
Prawdziwy produkt zazwyczaj obejmuje kilka takich zestawów reguł, z których każdy nie wie o istnieniu pozostałych:
cartStore → items, discount, total
authStore → user, session, permissions
themeStore → theme
notifyStore → toasts, unread count
W Redux to objawia się w postaci „slices” — po jednym reduktorze na każdą część drzewa — łączonych pod korzeniem bez łączenia ich logiki. W Zustand są to oddzielne wywołania create() (useCartStore, useAuthStore, …). W MobX są to oddzielne klasy obserwowalne z własnymi akcjami i niezmiennikami, bez wspólnego reduktora.
Komponent może odczytywać kilka magazynów podczas jednej renderizacji. Podsumowanie koszyka może odczytywać cartStore.total oraz authStore.user.currency, aby sformatować kwotę. Odczyty mogą odbywać się swobodnie. Zapisy natomiast nie mogą przekraczać tych granic: reducer koszyka nie powinien mieć dostępu do stanu autoryzacji, ani odwrotnie.
Jeśli zmiana w jednym magazynie wymusza zmianę w innym, aby zachować prawdziwość danych — na przykład zmiana waluty zmuszająca do ponownego obliczenia łącznej kwoty w koszyku — granice zostały błędnie wyznaczone. Albo te elementy należą do jednego, skoordynowanego magazynu, albo potrzebny jest wyraźny most synchronizujący je, a nie jakakolwiek domyślna zależność niewspomniana w dokumentacji.
Wybór biblioteki wciąż ma znaczenie dla konwencji zespołu, middleware’u oraz rozmiaru ekosystemu, ale to kwestie drugorzędne. Głównym kryterium jest struktura: wielu autorów oraz wspólne niezmienniki. Bez takiej struktury własne mechanizmy Reacta, takie jak Context, reducers i props, już obejmują większość przypadków, gdy potrzebne są globalne dostępy do danych. Przy takiej strukturze dedykowany magazyn danych — Redux Toolkit, Zustand, MobX lub starannie udostępniony useReducer — sprawdza się, ponieważ gromadzi wszystkie zasady w jednym miejscu.
Zespoły często odkrywają tę granicę późno, po tym jak procesy takie jak koszyk zakupów, rezerwacje czy matryca uprawnień już zawierają pięć miejsc wymagających modyfikacji. Dodanie reducera jest nadal tańsze niż naprawianie sporadycznych błędów w sumach. Im wcześniej niezmiennik zostanie nazwany w kodzie, tym mniej interfejs użytkownika musi kompensować to ad-hoc rozwiązaniami.
Jeśli funkcja ma zawsze tylko jednego autora i brakuje reguł międzydziedzinowych, należy ją zachować lokalnie. Jeśli ma wielu autorów oraz reguły, które muszą obowiązywać we wszystkich przypadkach pisania, należy zapewnić tym zapisom jedno wejście.
Prawdziwa odpowiedź
Wcześniejsze omówienia pokazały, że sama liczba czytelników nigdy nie jest wystarczającym powodem do utworzenia biblioteki – React już radzi sobie z takimi przypadkami. Ten tekst dotyczy drugiej połowy problemu: prawdziwym testem jest to, ile niezależnych miejsc może pisać oraz czy te zapisy muszą zachowywać te same fakty.
Flaga tematu nie przechodzi tego testu nigdzie: jeden autor, brak niezmiennika między polami, nic do skoordynowania. Koło transportowe przechodzi go wszędzie jednocześnie: pięciu autorów, trzy fakty, z których każdy może zostać złamany, funkcja redukująca, która zamienia „pięć osób musi pamiętać zasadę” na „zasada obowiązuje bezwarunkowo”, plus dwa przydatne efekty uboczne — debugowanie jako powtarzalna ścieżka czasu zamiast domysłów, oraz testy, które wywołują funkcję zamiast renderować ekran w celu pobrania liczby.
To jest kluczowa kwestia. Nie liczba komponentów. Liczba autorów i to, co musi pozostać prawdziwe we wszystkich z nich.
Innymi słowy: biblioteki do zarządzania stanem klienta to narzędzia służące do koordynacji autorów kodu, a nie do rozpowszechniania odczytujących. Rozprzestrzenianie się odczytujących to zadanie Reacta. Koordynacja autorów kodu — oraz niezmienne, których muszą przestrzegać — to moment, w którym Redux, Zustand, MobX lub podobna biblioteka staje się właściwą odpowiedzią, a nie tylko nawykiem.