Редьюсеры и контекст: из разрозненных setState — в тестируемые переходы
useReducer моделирует многопольные обновления как именованные переходы в одной чистой тестируемой функции вместо разбросанных setState. Раздельные контексты state и dispatch: dispatch стабилен, пишущие компоненты не ре-рендерятся. Часто это заменяет стейт-библиотеку.
Баг в чекауте, дошедший до финансового директора, формулировался просто, а воспроизводился мучительно: заказ ушёл с бесплатной доставкой и со стоимостью доставки, включённой в итог. Компонент чекаута держал одиннадцать полей useState — товары, промокод, скидка, способ доставки, её стоимость, итоги — и инвариант «итог = товары − скидка + доставка» поддерживался вручную в шести разных обработчиках событий. Обработчик «применить промокод» обновлял скидку и итог; обработчик «сменить доставку» — доставку и итог; коллега месяцем раньше добавил экспресс-чекаут и обновил пять мест из шести. QA не поймал, потому что сломанный путь требовал применить промокод, пока оценка доставки ещё грузится. Переписывание оказалось почти механическим: один useReducer, экшены promo-applied и shipping-selected, итоги пересчитываются ровно в одном месте — в редьюсере — и юнит-тест на двенадцать строк, проигрывающий падающую последовательность экшенов без DOM, без React, без моков. Тест падал на старой логике, проходил на новой — и класс багов исчез.
К концу урока вы будете знать, когда разрозненные setState — это баг в ожидании, и как одна чистая функция устраняет весь этот класс проблем.
Переходы, а не поля: что на самом деле меняет useReducer
У формы с десятью вызовами useState нет архитектуры — есть десять независимых значений и надежда, что каждый обработчик обновит нужное подмножество. Сценарий отказа — дрейф инварианта: правило вроде «итог отражает скидку и доставку» не живёт ни в одном конкретном месте, и каждый новый обработчик — шанс обновить четыре поля из пяти нужных. useReducer переворачивает структуру. Состояние становится одним объектом; обновления — экшенами, именованными событиями, описывающими, что произошло (promo-applied), а не какие поля записать (setDiscount + setTotal). Редьюсер — единственное место, где события превращаются в следующее состояние:
function checkoutReducer(state, action) {
switch (action.type) {
case "promo-applied": {
const discount = computeDiscount(state.items, action.code);
return withTotals({ ...state, promoCode: action.code, discount });
}
case "shipping-selected": {
return withTotals({ ...state, shipping: action.method });
}
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// withTotals — ЕДИНСТВЕННОЕ место, где живёт инвариант:
function withTotals(state) {
return { ...state, total: subtotal(state) - state.discount + shippingCost(state) };
}
const [state, dispatch] = useReducer(checkoutReducer, initialCheckout);
// Обработчики сжимаются до намерения: dispatch({ type: "promo-applied", code });Механика: dispatch планирует ре-рендер, и React вызывает ваш редьюсер с текущим состоянием и экшеном при следующем рендере — поэтому редьюсер обязан быть чистым: никаких запросов, никакого Date.now(), никакой мутации входящего состояния (в StrictMode редьюсер вызывается дважды ровно затем, чтобы это поймать). Компоненты сжимаются до диспетчеризации намерений; логика решений покидает компонент целиком.
Чистая логика — тестируемая логика
Спросите себя: как написать регрессионный тест на баг, который проявляется только в определённой последовательности двух событий, без DOM? С редьюсером ответ — три строки. Поскольку редьюсер — чистая функция (state, action) => state, самая баго-опасная логика фичи — многопольные инварианты, последовательности краевых случаев — становится юнит-тестируемой без рендеринга чего-либо. Регрессионный тест из Hook — три строки проигрывания: стартуем с известного состояния, диспатчим shipping-estimate-loaded, затем promo-applied, проверяем итог. Без JSDOM, без act(), без замоканной сети — тесты редьюсера выполняются за микросекунды и прибивают последовательности, а именно там прячутся баги разрозненных setState. Это недооценённый выигрыш: команды берут редьюсеры «для порядка», а обнаруживают, что главный приз — логика состояния наконец получает ту же тестовую дисциплину, что и остальная кодовая база. Компромисс: церемония реальна — типы экшенов, switch, прослойка между кликом и записью. Для двух полей без инвариантов useState строго лучше; редьюсер окупается, когда поля обновляются вместе по правилам.
Разделение state и dispatch: гигиена ре-рендеров как архитектура
Чтобы раздать состояние редьюсера по поддереву фичи, его передают через контекст — и тут гигиена ре-рендеров из юнита 02 становится архитектурным решением. Контекст ре-рендерит каждого потребителя, когда идентичность его значения меняется. Если публиковать { state, dispatch } одним объектом, каждый потребитель ре-рендерится на каждое изменение состояния — включая кнопки тулбара, которые только диспатчат. Лечение структурное: два контекста.
const CheckoutStateContext = createContext(null);
const CheckoutDispatchContext = createContext(null);
function CheckoutProvider({ children }) {
const [state, dispatch] = useReducer(checkoutReducer, initialCheckout);
return (
<CheckoutDispatchContext.Provider value={dispatch}>
<CheckoutStateContext.Provider value={state}>
{children}
</CheckoutStateContext.Provider>
</CheckoutDispatchContext.Provider>
);
}React гарантирует, что dispatch ссылочно стабилен между рендерами — значение dispatch-контекста никогда не меняется, поэтому компоненты «только записи» (кнопки, пункты меню, баннер экспресс-чекаута) подписываются на него и никогда не ре-рендерятся от изменений состояния. Читающие компоненты подписаны на state-контекст и ре-рендерятся при изменении состояния — честно, ведь они его отображают.
▸Почему это работает
Почему этот паттерн часто заменяет стейт-библиотеку? Потому что для домена в масштабе фичи — чекаут, панель редактора, визард — он уже даёт три вещи, ради которых импортируют Redux: централизованные переходы, тестируемую чистую логику и общий доступ вниз по поддереву. Чего он не даёт — того, что библиотеки реально добавляют: точности подписки уровня селекторов (каждый потребитель state-контекста ре-рендерится на любое изменение состояния — подписаться только на state.total невозможно), devtools с историей экшенов и middleware. Правило выбора: масштаб фичи, умеренная частота записи, потребители читают пересекающееся состояние → редьюсер + контекст, ноль зависимостей. Всё приложение, частые записи или потребители с непересекающимися срезами → внешний стор, следующий урок.
Форма чекаута поддерживает инвариант «итог = товары − скидка + доставка» в шести обработчиках через несколько useState, и инвариант постоянно ломается. Что структурно меняет переход на useReducer?
Почему разделение состояния редьюсера и dispatch на два отдельных контекста избавляет пишущие компоненты от ре-рендеров, а один общий контекст — нет?
- 01Многопольная форма постоянно ломает инвариант, который несколько обработчиков поддерживают вручную. Объясните, как редьюсер устраняет этот класс багов и почему он обязан оставаться чистым.
- 02Когда редьюсер + контекст законно заменяет стейт-библиотеку и от чего вы отказываетесь — назовите точное ограничение по ре-рендерам.
Несколько полей useState, обновляющихся вместе по правилам, — это инвариант, ждущий дрейфа: правило живёт в каждом обработчике и потому ни в одном, а баг приезжает со следующим обработчиком, который кто-то добавит. useReducer перестраивает фичу вокруг переходов — обработчики диспатчат именованные экшены о произошедшем, и одна чистая функция превращает текущее состояние и экшен в следующее состояние. Инварианты получают единственный дом; компоненты сжимаются до выражения намерений. Механика: dispatch планирует ре-рендер, и React выполняет редьюсер во время рендеринга — поэтому он обязан быть чистым: без запросов, без чтения часов, без мутаций; StrictMode вызывает его дважды, чтобы ловить нарушения. Чистота покупает практический приз: логика состояния становится юнит-тестируемой проигрыванием последовательностей экшенов за микросекунды, без DOM и моков — так баги, зависящие от порядка событий, получают регрессионные тесты. Раздача редьюсера по поддереву применяет гигиену ре-рендеров архитектурно: публикуйте state и dispatch двумя отдельными контекстами. Dispatch ссылочно стабилен, и пишущие компоненты не ре-рендерятся; потребители state-контекста ре-рендерятся на каждый переход — честно для отображающих, впустую для читающих неизменившийся срез, потому что у контекста нет селекторов. Эта недостающая точность плюс devtools и middleware — то, что всё ещё добавляет стейт-библиотека: редьюсер + контекст для доменов масштаба фичи, внешние сторы — когда записи частые или читатели не пересекаются. Теперь, когда вы увидите компонент, который только диспатчит, но ре-рендерится на каждое изменение состояния, вы исправите это разделением контекстов за полминуты.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.