Чаму Redux чыра Zustand належаць да багато пісцачых, а не да багато чытачоў
Колькасць чытачоў недастатнія як прычына для стварэння магазіна кліента. Калі калькі лічыльнікаяў дзелаюць разам, напрыклад, у варотках для пакупак, тады редукеры, перагляд і прымітныя тэсты становяцца неабходнымі.
Чатыры распашчыя падставы для выкарыстоўвання бібліятэкаў на кшталт Redux, Zustand або падобных былі разглянуты раней: праходжэнне прама через пропы, апдэйты з боку кліента, автаматычная дастаўка змян усім падпісчыкам, а таксама Context як безбеднейшы стандарт. React вже падтрымлівае гэтыя сценарыі без дапамогі дадатковага хранальніка.
Ні адна з гэтых аргументацый не ставіла таго пытання, якое насправдзе вялікае значэнне мае для выбору бібліятэкі. Не пра тое, сколькі компонентов чытаюць значэнне, а пра тое, сколькі незв’язаных месцаў можа зменіць яго, і чыя бы тое змены пасля таго не мусілі застацца супараднымі.
У разе пераключэння тэмы існуе ўсьведамлёны выконаваць — адна кантрола, адны вызов setTheme. Самэльчына бібліятэка тут не была неабходная, незалежна ад таго, сколькі экранаў выкарыстоўвае яе рэзультат. Шопінг-картыця — гэта іншы прыклад: калькі выконавацяў вплываюць у роботу з разных напрамкаў, і тут патрэбен іншы прыклад.
Адзін выконаваць проты калькоў
На карточцы товара корзіна показваецца як кнопка «Дадзіць у корзіну», у віддзеле — як прыстрой для кантролю колькасці, як лінк для выдалення, як поле купона, яке перыякруцьвае загальную суму, і як элемент для чыставання всіх дадзеных. Пяць файлаў, пяць месца мутацый, кожна з яых можа змяніць той самы стан, прычаму ніхто з іх не ведае пра абароны чатыры.
Поручыце з гэтым флаг тэмы: адна кнопка, адны «писар». Кожны чытальнік проста паказвае апошняе значэнне. Няма коордынацыі, таму што руль крутіць толькі адна рука.
Корзіна мае інварыянты — трохі з іх. Інварыянт — это факт, які павінен застацца правдзівым пасля будзь-яй операции. Для гэтай корзіны: загальная сума завжды доравнае суме цены кожнага пункта замовлення у помножэнні на колькасць, за вырачэнням дыючай зніжкі; колькасць ніколі не стае ад’ютывной; два пункты замовлення ніколі не маюць аднаковага SKU — дадзенне ўтрохце іншай екземпляра вялікага товара падымае колькасць, а не стварае дублікатную строчку.
Пяцьцаў пісьменнікаў, тры факты, якія кожны пісьменнік должен захаваць. Гэтая проблема, якую прагнуць рашыць Redux, Zustand чы MobX, і яна не мае нічынага з тым, сколькі компанентаў толькі чытаюць карточку пакупкі.
Што выходзіць з-пад кантролю, калі пяцьцаў незалежных пісьменнікаў зменяюць тые ж тры факты, калі немае нічога, што гэта контролюе?
Што ламаецца без аднаго дисцыплінованага месца
Уявіце сабе тры з тых пяцьцаў месцаў для мутацый, кожна з яых працюе ў стылі, выбранам власнікам файлу, і якія безпасэродна зменяюць об’ект карточкі пакупкі:
// 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 – это простая функцыя пошуку: заходзіце код, а выходзіць номер зніжкі. Файлы AddToCartButton.jsx і QuantityStepper.jsx оба перыяконлівалі значэнне cart.total. Файл CouponInput.jsx гэтага не рабіў. Нецыянтнае правіла “загальная сума = сума элементаў за вырачэнне зніжкі” было наражанае, а система нічога не зрабіла – ніхто не кераваў гэтым правілам. Гэта была традыцыя, па якой спакульваліся тры файлы; аднам з іх праста забылася.
Редюсер усуне гэтае довяр’е, вымагаючы, каб усі змены водзіліся чераз адну функцыю. Редюсер берае текущы стан і дзеянне (просты об’ект, які описвае тое, што адбылася) і вяртае абсалютна новы стан. Ён ніколі не мутуеўць стары об’ект на месцы.
// 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 выконваецца ў кожнай гілцы switch — не таму, што кожны аўтар прыгадаў пра гэта, а таму, што викорыстанне адной функцыі і адного switch ускладнюе пропуск певных крокаў. Тры коласцовыя адпаведнікі тепер описваюць запуск задач уместо таго, каб іх выкананнія адбывалася безпасуль:
// 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 выйшаў з маленькага файлу CartContext.jsx, які спаўнае useReducer (вбудованы хук, які вяртае стан і функцыю dispatch) з Concept of Context, так што кожны компонент можа выкорыстаць гэтую функцыю dispatch. Ні кнопкі, ні элементы для керавання прагамой больш не запісваюць cart.total = .... Ніхто з іх не патрэбуе ведаць пра наявнасць computeTotal.
Даныя залишаюцца ачытымі. Компаненты можа ўсё ж чытаць cart.total для адображэння цены, падсумку практыкі або спавешчэнняя пра незашытые элементы. Запісы праходзяць толькі адной дорагай: выканаўчыця дзеяння, пасля чаго редюсер стварае наступны стан. Цяная незменнасць знаходзіцца самэль у гэтым вузле, а не ў тым, хто яго вызывае.
Будзьце точны: адна функцыя запісу не робіць правіло правильным — яна робіць його выеанаванне адносовым. Якщо сам computeTotal забудзе прызнак, кожны шлях запісу будзе ўсё час ствараць той самы некоректны падсумак. Гэта яшчэ краща, чым розрозненыя версіі: аднообразны баг лёгка ізолюваць. Баг, які з’яўляецца толькі тады, калі якась файл забудзе лінію, набагато важка знайсці, таму што правильныя і некоректныя шляхі выглядаюць аднакова, пакуль не стане зрозумелым відсутны случай.
Калі шлях купона забывае пры перасчытанні, інтерфейс можа застацца нормальным, пакуль не адбудзецца змена колькасці, якая спрычыніць перасчытанне — або пакуль на экране практыкі не будзе показана загальная сума, якая больш не падходзіць да складовых элементаў. Самэ гэта перыядычнае становішча ўсё тое, чаму стандарты не функціонуюць: такія бракаванні ў шляхах зустрічаюцца рэдка, ўсё ж дазволяючы працаваць без проблем, але ўодночас дастаткова часта, каб стварыць проблэмы.
Централізацыя правіла не падрыхтавляе неабходнасць аддзёвклівай логіки computeTotal. Але яна гарантуе, што кожны шлях для запісу выконвае тую ж функцыю, таму некоректныя формулы будуць аднаковымі і таму можна ўсё ж іх знайсці. Разрозненыя апдэйты маскуюць тыя ж працынкі пад фразай «здароўа працуе».
Адзін дисцыплінованы шлях для запісу ўжо сам по сабе мае вялікую цэннаць. Што ўсё яшчэ ён дае, калі пазней ўсё-таки з’яўляюцца проблэмы?
Кожная дзеянне стае можлівасцю павторэння
Два атрыбуты редюсера, размешчаныя ўнаследоўваючы адзін другога, ствараюць ўсё моцнейшы элемент, чым проста файл логаў.
Паўначальнае — дзеянні ўможлівы да серыялізацыі: звычайны JSON без функцый, экземпляраў класоў чы схованых паказначэнняў — толькі { type: 'APPLY_COUPON', code: 'SAVE10' }. Другае — cartReducer ўсёчысты: ідэнтычны стан плюс ідэнтычнае дзеянне завжды даюць ідэнтычны наступны стан, без жадных зоваў з званэй часткі.
Разам перагляд последовасці з таго ж пачатку завжды прыводзіць да таго ж канца. Самэй гэты детермінізм і выкарыстоўваецца ў Redux DevTools. Ён не проста выводзіць, што ўсё-такі адбылася якаясь дзеянне; ён зберагае список дзеяння як даны і перасчыляе точны кадр пасля кожнага выбранага крока, калі яго нажмаць.
Возьмем паказаны ранейшаі баг — сума абсалютна некалькуючая пасля застосавання купона, а потым выдалення айтэма. Калі запускаюцься DevTools, сеанс паказвае команды ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Пасля APPLY_COUPON сума выглядае правільна; пасля REMOVE_ITEM — няўсёле. У бага ёсць конкрэтны адрэс — галузь REMOVE_ITEM — без неабходнасці выклікаць console.log чы рээмберування сеанса пользователя вручную.
Гэты прыямлівас не ўсюды аднаковы ў бібліятэках. Redux DevTools — гэта зрэлыя, первасны варыянт. Мідлвер devtools у Zustand падключае функцыю set() да той самай расширэння, таму апдэйты праказваюцца як дзеяння з іменамі. MobX зазвычай мутуе спостерагаемыя элементы безпосередна через проксі, а не через ланцюг серыялізаваных дзеянняў, таму яго інструменты акцэнаваюць на графах реакцый — якія вычысленні былі паўтараны і чаму — больш, чым на полным журнале паўтароўчых дзеянняў.
Replay патрэбуе детерміністычнай функцыі, якая завжды перадае тыя ж вхідныя даны ў тыя ж выходныя. Што ещэ гэта дае паza межамі расширэння браузера?
Редюсер — гэта проста функцыя, якую можна вызваць
cartReducer(startState, action) — гэта звычны вызов: аргументы заходзяць, весь наступны стан выходзіць. Для тэставання яго не патрэбны браузер, клікі чы выражаны компоненты.
// 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
});
Тэст завершаецца за калькуляцыю мілісекундаў без выкарыстоўвання бібліятэкі для адрасавання. Ён безпасебна выяўляе паказваны ранейя баг: якщо APPLY_COUPON праігноравае computeTotal, то nextState.total застаецца равным 40, і тады асэрцыя парабуліе на несправным шляху, а не з-за расходнай нясумаснасці ў інтерфейсе.
Такой жа логікі, як у функцыі-зменніку useState унутроць обрабоўчыка кліку, нельга тады перапрацавваць — і прычына ў гэтым не 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;
Выкліканне useCoupon() з простага тэсту вызывае памылку. Хукі на кшталт useState працуюць толькі пад час адрасавання React (або ўнутроць іншага хука, які выклікаецца пад час адрасавання), пры чым вони фіксуюцца ўзгодна з таму элементам компанеты. Гэта і є правілы хуков: безусловныя выклікі ў той самы порядак пад час кожага адрасавання, таму што React пашукае стан хука па месцы выкліку, а не па імени. Якщо праігнораваць хук пад час дзеякіх адрасаванняў, то ведчыцтва стану стане несумасным.
applyCoupon таксама не вярта корисны стан у тым спосабом, як cartReducer. Функцыя setDiscount вяртае undefined. Яна запланавае паўтарную нарадзіцельнасць; новая значэнне показваецца толькі пасля наступнага запуску корпусу компонента. Не існуе значэння, якое можна было б пераканацца — механізм складаецца з “запросу да React пра паўтарную нарадзіцельнасць”, а не з “вычыслення і вярнення значэння”.
Тэставанне яго абяцкае нарадзіцельнасць чаго-небудзь і чытанне таго, што показалася на экране:
// 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');
});
Той самы факт, які тэстуецца — загальная сума пасля выкарыстання купона — але перакананне пераглядае текст, які показаўся пасля повной нарадзіцельнасці і сімульаванага кліку.
Середнім варыянтам ёсць renderHook з React Testing Library: можна выкорыстаць гука без JSX чы ручных натысканняў, а пасля перагледзець result.current. Для гэтага все ж неабходны тэстовы рендерар React пад act, які выконвае апдэйты і эфекты пры перакананні. Структура залишаецца тая ж, калі стан гука яшчэ знаходзіцца ў запусканай (нават мінімальной) інстанцыі. cartReducer не патрабаваў нічога з гэтага; ён ніколі не быў прыяўязаны да компонента.
Тэставанасць стосуецца зв’язку між элементамі. Как выглядае тая ж ідея, калі трэба выбраць месца, дзе адны стоуры заканчываюцься, а іншыя пачынаюцца?
Дзе на самай працоўцы знаходзіцца межа
Начальныя інварыянты таксама адпавядаюць на іншы вопыт: дзе адны стоуры должны завершыцца, а наступныя — пачацца.
Элементы items, discount і total каравану належаць разам, таму што яны взаемна пов’язаны — змена аднаго можа зробіць недзейнасцю тыя факты, якія залежаць ад іншых. theme не належыць у гэты редюсер: нічога, што стосуецца theme, не вплывае на cart.total, а нічога, што стосуецца каравану, не вплывае на theme. Цэй ўзаемна незалежны стан, які проста існуе ў адной программе.
Рэальны продукт зазвычай мае колька такіх набораў правіл, пры чым кожны з іх не ведае аб іншых:
cartStore → items, discount, total
authStore → user, session, permissions
themeStore → theme
notifyStore → toasts, unread count
У Redux гэта выглядае як “скаівы” — адзін редюсер на кожную частку дрэва — якія з’едынаюцца пад коранам, але без злучэння ўсіх іх логік. У Zustand гэта адзелёныя вызовы create() (useCartStore, useAuthStore, …). У MobX гэта адзелёныя класы-спостерагачы з іх сае своімі дзеяннямі та незменнымі параметрамі, без спакульнага редюсера.
Адын элемент можа чытаць калькі магазінаў падчас адной рэндарацыі. Падсумак кошыка можа чытаць cartStore.total і authStore.user.currency, каб сформатаваць суму грошэй. Чытанне можа вестыцца свабодна. Запіс не павінен перакрывацца: редюсер кошыка не павінен заходзіць у стан аутэнтыкацыі, і навпака.
Якщо для таго, каб факт застаўся правдзівым, падчас змены аднаго магазіна неабходна таксама зменіць іншы — напрыклад, змена валюты вымагае перасчытку загальнай сумы кошыка — граніцы былі неправильна паказаны. Альбо этыя элементы павінны належаць да адного скоординаванага магазіна, альбо ім патрэбен явны мост, чыя задача — падтрымліваць синхроннасць, а не няўыразная залежнасць, якую ніхто не задокументаваў.
Выбор бібліятэкі яшчэ мае значэнне для стандартав команды, мідлвэра і розмеру экасистемы, але гэта ўспакойнае пытанне. Галоўны фільтр — структурны: калькольнікаў-апісчыкаў плюс спакойныя значэння. Без такой структуры самы Context, reducers і props у React уже падтрымліваюць большасць ситуацый, калі патрэбны глобальныя чытанні. З такой структурам спецыяльны склад — Redux Toolkit, Zustand, MobX або рэштрыкавана выкарыстоўваны useReducer — прадставляе сабою ўсё неабходнае, таму што правілы зберагаюцца ў аднам месцы.
Команды часта адкрываюць гэтыя межы пазней, калі карточка абдзялу, процес броніравання чыста матрыца правоў вже маюць пяць месцаў для змены. Адкорэгаванне редуктара яшчэ дышэць дагадзе, чым адлучванне прычын періядычных памылак. Чым раней спакойнае значэння называецца ў кодзе, тым менш UI патрэбна компенсацыя за дапамой тады-тады патчаў.
Якщо яка-небудзь функцыя мае толькі адного автара і няма правіл перакрыцча межама, трэба заставіць яе працаваць локальна. Якщо ж у яе є багато автараў і правіла, якія павінны застацца чыннымі для кожнага з іх, тады трэба стварыць адны спосаб для зберагчэння ўсіх змян.
Практычны адказ
У пакульшых аналізах было показана, што сама лічба чытаўцоў ніколи не ёсць падставай для стварэння бібліятеки — React вже рашае такія ситуацыі. У цім матэрыяле рассматрываецца другая частка: сколькі незалежных месцаў можа выконваць змены, і чыяць гэтыя змены павінны застацца аднойчынымаў, — гэта справжнія тэст.
Тэматычныя флагі не працуюць па гэтым критэрыю нідзе: адны пісьменнік, жадна незалежная вялічына для разных сфер, нічога, што можна было б скоординаваць. Карточка працюе па гэтым критэрыю негайна ў всіх випадках: пяць пісьменнікаў, тры факты, якія кожны з іх можа спачатку, прыемнік, який ператварае «пяць чалавек мусіць памятаць правілу» на «правілу дыяе без умов», плюс два корыстныя пасляэфекты — адлагоджэнне як відтворюемая хроналогія заместо згадків, і тэсты, якія вызываюць функцыю заместо адрасавання экрана для выкарыстоўвання цифры.
Гэтае і є ключовае правілу. Не колькасць компанентоў. Колькасць пісьменнікаў і тое, што мусi застацца правдай для ўсіх іх.
Іншымі словамі: бібліятэкі для стану кліента — это інструменты для коордынацыі прадаўчыкаў, а не для распадзелу чытачаў. Распадзел чытачаў — гэта задача React. Коордынацыя прадаўчыкаў, а таксама некалькі незменных правілаў, якія прадаўчыкі должны прыменяць, — гэта тады, калі Redux, Zustand, MobX або адпаведная бібліятэка стану стае правдзівай апавядомленнем, а не проста звычкай.