Главная / Статьи / Почему Redux или Zustand подходят для ситуаций с множеством авторов, а не множеством читателей

Почему Redux или Zustand подходят для ситуаций с множеством авторов, а не множеством читателей

Количество читателей — это недостаточная причина для создания хранилища данных клиента. Когда несколько авторов работают с одними и теми же данными — например, корзиной покупок — тогда и приходят в дело функции уменьшения объема данных, повторная обработка и прямые тесты.

2370 слов

Четыре распространённых обоснования для использования 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 — это зрелый оригинал. Мидлвэрк 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, и проверка провалится на этом некорректном пути, а не из-за расхождений в интерфейсе.

Таким же образом невозможно протестировать логику, аналогичную установщику 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. Чтение данных происходит свободно. Однако запись данных не должна пересекаться: редьюсер корзины не должен влиять на состояние авторизации, и наоборот.

Если для сохранения верности какого-либо факта изменение одного хранилища требует изменения другого — например, смена валюты приводит к пересчёту общей суммы в корзине — границы разделения ответственностей определены неверно. Либо все эти элементы должны находиться в одном согласованном хранилище, либо им необходим механизм синхронизации, а не косвенная зависимость, которая нигде не описана.

Выбор библиотеки по-прежнему важен для стандартов команды, промежуточного программного обеспечения и размера экосистемы, но это второстепенно. Основной критерий — структурный: несколько авторов кода плюс общие константы. Без такой структуры собственные механизмы React, такие как Context, reducers и props, уже покрывают большинство случаев, когда требуется глобальный доступ к данным. С такой структурой специализированный хранилище данных — Redux Toolkit, Zustand, MobX или тщательно организованный компонент useReducer — выполняет свою функцию, сосредотачивая все правила в одном месте.

Команды часто осознают этот принцип слишком поздно, когда уже появляется пять мест для изменений в функции обработки заказов, процесса бронирования или матрице прав доступа. Внедрение reducer всё равно дешевле, чем отладка случайных ошибок в расчётах. Чем раньше константы называются в коде, тем меньше интерфейсу приходится использовать временные решения для компенсации.

Если для какой-либо функции существует только один автор и нет правил междисциплинарного взаимодействия, следует сохранять её локально. Если у неё много авторов и существуют правила, которые должны действовать независимо от каждого автора, тогда для этих записей необходим единый механизм управления.

Фактический ответ

Ранее уже говорилось, что одного лишь количества читателей недостаточно для обоснования наличия библиотеки — React уже справляется с подобными случаями. В данной статье рассматривается второй аспект: насколько много независимых источников может вносить изменения, и требуется ли, чтобы эти изменения сохраняли те же факты — вот что является настоящим критерием.

Флаг темы не проходит этот тест ни в одном случае: один автор, отсутствие междисциплинарной неизменной величины, ничего, что можно было бы скоординировать. Корзина проходит его одновременно во всех случаях: пять авторов, три факта, любой из которых может быть нарушен, функция-сводка, преобразующая требование «пять человек должны помнить правило» в утверждение «правило действует безусловно», плюс два полезных побочных эффекта — отладка в виде воспроизводимой временной шкалы вместо догадок, и тесты, вызывающие функцию вместо отображения экрана для извлечения числа.

Важен именно этот критерий. Не количество компонентов. Количество авторов и то, что должно оставаться верным для всех из них.

Другими словами: библиотеки для управления состоянием клиента — это инструменты для координации операций записи, а не для распространения данных среди читателей. Распространение данных среди читателей — это задача React. Координация операций записи, а также неизменяемые правила, которым должны следовать эти операции записи, — вот когда Redux, Zustand, MobX или аналогичные решения становятся разумным выбором, а не просто привычкой.