Чаму Redux чыра Zustand належаць да багато пісцачых, а не да багато чытачоў
Колькасць чытачоў недастатнія як прычына для стварэння магазіна кліента. Калі калькі лічыльнікаяў дзелаюць разам, напрыклад, у варотках для пакупак, тады рэдукеры, перагляд і прымітныя тэсты становяцца неабходнымі.
Крыніцы: The One Real Reason To Use Redux, Zustand, Or Any State Management Library
Чатыры распашчыя падставы для выкарыстоўвання бібліятэкаў на кшталт 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 або адпаведная бібліятэка стану стае правдзівай апавядомленнем, а не проста звычкай.