Чому 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) із Context, щоб будь-який компонент міг отримати доступ до цієї функції dispatch. Ані кнопки, ані інструменти керування більше не записують cart.total = .... Жоден з них не потребує знати про існування computeTotal.
Читання залишається відкритим. Компоненти все ще можуть читати cart.total для відображення ціни, підсумків оформлення замовлення чи сповіщень про незбережені елементи. Записи проходять лише одним шляхом: відправляється дія, після чого редюсер генерує наступний стан. Ця незмінна правило знаходиться саме в цьому контрольному пункті, а не у тому, хто його викликає.
Будьте точні: одна функція запису не робить правило правильним — вона забезпечує послідовне його дотримання. Якщо сам computeTotal забуде про знижку, кожен шлях запису буде завжди формувати однакову неправильну суму. Це все одно краще, ніж розрізнені версії: єдиний баг легко ізолювати. Баг, який з’являється лише тоді, коли один файл забуває певний рядок, набагато складніше знайти, адже правильні та неправильні шляхи виглядають ідентично, доки не станеться відсутній випадок.
Коли шлях купона ігнорує повторний розрахунок, інтерфейс може здаватися у порядку аж до наступної зміни кількості, яка спричинить перерозрахунок — або поки на етапі оплати не буде показана сума, яка більше не відповідає окремим позиціям. Саме це періодичне явище є причиною невдачі стандартних підходів: пошкоджений шлях зустрічається рідко настільки, щоб пройти непоміченим під час випадкових кліків, але достатньо часто, щоб спричинити проблеми.
Централізація правил не усуває потреби в ретельній логіці функції computeTotal. Вона гарантує, що кожен шлях запису використовуватиме однакову функцію, тож неправильна формула буде послідовною та, отже, можливою до пошуку. Розрізнені оновлення приховують ту саму помилку під маскою «в основному працює».
Один дисциплінований шлях запису має цінність сам по собі. Що ще він дає, коли пізніше щось ламається?
Кожна дія стає повторюваним знімком
Поєднання двох властивостей редюсера дає щось сильніше, ніж просто файл журналу.
По-перше, дії є серіалізованими: звичайний JSON без функцій, екземплярів класів чи прихованих посилань — лише { type: 'APPLY_COUPON', code: 'SAVE10' }. По-друге, cartReducer є чистим: ідентичний стан та ідентична дія завжди призводять до ідентичного наступного стану, без будь-яких зовнішніх читань.
Разом повторне виконання послідовності з того самого початку завжди призводить до того самого кінця. Саме цей детермінізм використовують Redux DevTools. Вони не просто виводять інформацію про те, що щось сталося; вони зберігають список дій як дані та перераховують точний знімок після будь-якого обраного кроку при його натисканні.
Візьмемо попередню проблему — загальна сума різко змінюється після застосування купона, а потім видалення товару. Коли відкриті інструменти розробника, сеанс містить команди ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Після APPLY_COUPON сума виглядає правильною; після REMOVE_ITEM — ні. У цієї проблеми є конкретна причина — гілка REMOVE_ITEM — без необхідності додавання команд console.log чи вручну повторного відтворення сеансу користувача.
Ця перевага не є однаковою у всіх бібліотеках. Redux DevTools — це зріла оригінальна версія. Middleware devtools у Zustand підключає функцію set() до тієї самої розширення, тож оновлення відображаються як дії з іменами. MobX зазвичай мутує об’єкти спостереження безпосередньо через проксі, а не через ланцюжок серіалізованих дій, тому його інструменти наголошують на графах реакцій — які обчислення були перевиконані та чому — більше, ніж на повному журналі подорожей у часі.
Replay потребує детерміністичної функції, яка завжди перетворює однакові вхідні дані на однакові вихідні. Що ще це дає окрім роботи з розширенням браузера?
Редюсер — це просто функція, яку можна викликати
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, і перевірка провалиться на пошкодженій гілці коду, а не через нечітку невідповідність інтерфейсу.
Так само неможливо протестувати логіку, схожу на використання setter для 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, не визначає 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, щоб сформатувати суму. Читання може відбуватися вільно. Однак записи не повинні перетинатися: редюсер кошика не повинен впливати на стан автентифікації, і навпаки.
Якщо для збереження правдивості даних зміна одного сховища вимагає зміни іншого — наприклад, зміна валюти змушує перерахувати загальну суму кошика — межі були визначені неправильно. Або ці елементи мають бути в одному координованому сховищі, або їм потрібен явний міст, чиєю функцією є підтримання синхронності, а не неявна залежність, про яку ніхто не документує.
Вибір бібліотеки все ще має значення для стандартів команди, середовища проміжного програмування та розміру екосистеми, але це є другорядним. Основний фільтр — структурний: кілька авторів та спільні інваріанти. Без такої структури власні механізми React, такі як Context, reducers та props, вже покривають більшість ситуацій, коли потрібен глобальний доступ до даних. З такою структурою спеціалізований сховище даних — Redux Toolkit, Zustand, MobX або ретельно спільновикористовуваний useReducer — ефективно виконує свою функцію, оскільки всі правила знаходяться в одному місці.
Команди часто усвідомлюють цей рубіж занадто пізно, коли система керування кошиком, процес бронювання чи матриця дозволів вже містить п’ять точок змін. Внесення змін у reducer все одно дешевше, ніж виправлення періодичних проблем з підсумками. Чим раніше інваріант отримує назву в коді, тим менше інтерфейсу доводиться компенсувати це тимчасовими рішеннями.
Якщо певна функція має лише одного автора та не містить правил міждисциплінарної співпраці, залиште її локальною. Якщо вона має багатьох авторів та правила, які мають діяти незалежно від кожного автора, надайте цим записам єдиний вихід.
Фактична відповідь
Попередні матеріали показали, що сама кількість читачів ніколи не є підставою для створення бібліотеки — React вже вирішує такі ситуації. У цій статті розглядається інший аспект: скільки незалежних місць може виконувати записи, та чи мають ці записи зберігати однакові факти — ось справжнє випробування.
Флаг теми провалює цей тест скрізь: один автор, відсутність міжполемних інваріантів, нічого для координації. Кошик проходить його одночасно скрізь: п’ятеро авторів, три факти, які будь-хто з них може порушити, функція-редуктор, яка перетворює „п’ять осіб мають пам’ятати правило“ на „правило діє безумовно“, плюс два корисні побічні ефекти — дебаггінг у вигляді повторюваної часової шкали замість здогадок, та тести, які викликають функцію замість відображення екрана для отримання числа.
Ось ключовий момент. Не кількість компонентів. Кількість авторів та те, що має залишатися істинним у всіх них.
Іншими словами: бібліотеки для керування станом клієнта — це інструменти для координації записувачів даних, а не для розповсюдження читачів. Розподіл читачів — це завдання React. Координація записувачів та незмінні умови, яких вони повинні дотримуватися, — ось коли Redux, Zustand, MobX або подібна бібліотека стає справжньою рішенням, а не просто звичкою.