useReducer и размеченные объединения
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Пять useState в компоненте — не преступление, пока каждое значение живёт само по себе. Беда начинается, когда одно событие обязано изменить сразу три: правило «новый вопрос — пустой выбор» приходится повторять в каждом обработчике, и однажды его не повторяют. useReducer собирает все переходы в одну функцию, а обработчику оставляет одну строку — сказать, что произошло. Описывать эти сообщения вы уже умеете: это размеченное объединение из части III.
Квиз ниже держит три значения: номер вопроса, выбранный вариант и счёт. По отдельности они безобидны, но между ними есть правило: как только меняется вопрос, выбор обязан обнулиться. Записать это правило негде — оно живёт в обработчиках, и каждый новый обработчик обязан о нём помнить. Пример нажимает «вариант 2», а потом «заново». Предскажите последнюю строку вывода.
restart честно сбросил то, о чём помнил его автор, — номер вопроса и счёт, — а picked остался от прошлой попытки. Форма состояния тут ни при чём: три значения и правда независимы, и статья о структуре состояния к ним не придерётся. Противоречие принёс переход.Посмотрите, где живёт правило «новый вопрос — пустой выбор»: в next оно есть, в restart его нет, и связывает эти два места только внимательность. Появится третий переход — «пропустить», «вернуться назад», «выйти по таймеру» — и правило придётся написать в третий раз. Каждый обработчик знает свою часть истории, а всей истории не знает никто.
Признак болезни простой: в одном обработчике три сеттера, в соседнем — те же три, но одного не хватает.
Переход на useReducer — это три перестановки. Три useState собираются в один объект состояния. Обработчики перестают менять состояние и начинают объявлять о событии: «выбрали вариант», «дальше», «заново». А все переходы переезжают в одну функцию — редьюсер: он получает текущее состояние и объявленное действие, а возвращает следующее состояние.
Форма функции знакома по reduce из части I: накопитель плюс очередной элемент дают новый накопитель. Здесь накопитель — состояние экрана, а элементы — то, что делает пользователь.
Сам вызов похож на useState: const [state, dispatch] = React.useReducer(quizReducer, initial). Слева приезжают текущее состояние и dispatch — единственный способ что-то в этом состоянии изменить; справа — редьюсер и то, с чего всё начинается. Тот же квиз, та же кнопка «заново».
«Заново» больше не перечисляет, что сбросить: обработчик говорит, что случилось, а во что это превратит состояние, решает редьюсер — в одном месте и сразу для всех переходов. Правило «новый вопрос — пустой выбор» по-прежнему записано дважды, но обе записи видны на одном экране, в соседних ветках, а не в обработчиках, разбросанных по разметке.
И ещё одно различие, которое легче увидеть, чем объяснить. Помните два setCount(count + 1) подряд из статьи про снимок? Ниже в одном обработчике стоят два dispatch и два setByState. Предскажите обе цифры.
Два dispatch дали двойку, два setByState(byState + 1) — единицу. setByState(byState + 1) — это заказ на конкретное число, посчитанное из снимка; второй такой заказ просто повторяет первый. dispatch заказывает не число, а действие: React складывает действия в ту самую очередь из статьи про обновитель и прогоняет по ней редьюсер, подавая ему уже накопленное состояние. Функция-обновитель setCount(n => n + 1) делает то же самое — редьюсер и есть общий обновитель, вынесенный из компонента и знающий обо всех переходах сразу.
dispatch не меняется между отрисовками: это одна и та же функция от первого рендера до размонтирования.
Обещание про постоянство dispatch можно не брать на веру: сохраните функцию в переменную вне компонента и сравните на следующей отрисовке. Практическая польза — в зависимостях эффекта из статьи «Зависимости и устаревшие значения»: dispatch в списке зависимостей ничего не перезапускает, в отличие от стрелки, созданной в теле компонента.
Тип Action из примера выше — то самое размеченное объединение из части III: общее поле-метка type с литеральным типом, а у каждого варианта своя нагрузка. «Выбрали вариант» несёт номер, «дальше» и «заново» не несут ничего — и это не пожелание в комментарии, а часть типа.
В доске из части II действие ездило строкой в data-action, и опечатку в ней ловил только клик — если вы догадались нажать именно ту кнопку. Здесь мимо типа не проехать. Три вызова ниже сломаны по-разному; прочитайте, что компилятор скажет о каждом.
Компилятор отвечает на все три. «Аргумент типа { type: "picked"; } нельзя назначить параметру типа Action. Свойство option отсутствует в типе { type: "picked"; } и является обязательным в типе { type: "picked"; option: number; }» — нагрузку молча не забудешь. «Тип "nekst" не может быть назначен для типа "next" | "picked" | "restart"» — и заодно перечислены все допустимые метки: это и есть список того, что экран умеет. «Объектный литерал может использовать только известные свойства. option не существует в типе { type: "restart"; }» — знакомая проверка лишних свойств из части III: «заново» нагрузки не несёт, и приписать её не выйдет.
Метку называют по событию, а не по способу его обработать: picked, а не setPicked; restart, а не resetIndexAndScore. Разница не косметическая. Имя setPicked заранее решает, что именно изменится, и в тот день, когда выбор варианта начнёт ещё и останавливать таймер, имя соврёт. Список действий — это протокол экрана: по нему видно, что вообще на нём может случиться.
Поле с нагрузкой называют по смыслу — option, id, text. Имя payload пришло из Redux и ничего не объясняет.
Редьюсер разбирает действия через switch по метке, и внутри каждой ветки компилятор сужает тип — ровно как в статье про размеченные объединения: в ветке picked у действия есть option, в ветке next его нет и обратиться к нему нельзя. Пока веток столько же, сколько вариантов, всё сходится. Настоящая проверка приходит в тот день, когда в объединение добавляют вариант.
Страховка — приём из статьи «never и полный разбор вариантов»: в default присвоить действие переменной типа never. Если разобраны все варианты, до default доезжает пустой тип и присваивание законно. Если хоть один забыт, туда приедет он.
«Тип { type: "restart"; } не может быть назначен для типа never» — и номер строки той самой заглушки в default. Компилятор не сказал «где-то не хватает ветки»: он назвал забытый вариант по имени. Допишите case "restart" — и диагностика исчезнет сама, потому что до default снова начнёт доезжать пустой тип.
Вторая строка вывода показывает, что было бы без этой страховки: restart не изменил ничего. Пользователь жмёт кнопку, ничего не происходит, в консоли пусто — и искать нечего, потому что ошибки нет. Забытая ветка не падает, она молчит; тем и опасна.
Переменная forgotten нужна только компилятору и в каждом редьюсере выглядит одинаково. Функция assertNever из части III делает то же самое, но добавляет вторую линию обороны — на случай, если действие придёт откуда-то, где типов не было: из JSON.parse, из старой вкладки, из чужого кода.
А вот default: return state — заманчивая, но плохая привычка: она глушит и опечатки, и забытые ветки. Возвращать состояние без изменений редьюсер должен осознанно, в именованной ветке («ответ на уже отвеченный вопрос»), а не по остаточному принципу.
Редьюсер — не хук и не компонент: два аргумента, одно возвращаемое значение, никаких обращений к React. Поэтому его и объявляют вне компонента — пересоздавать функцию на каждой отрисовке незачем, — и поэтому его можно вызвать руками.
Проверка перехода не требует ни кнопки, ни браузера: подайте состояние и действие — получите состояние. А целый сценарий пользователя — это reduce по массиву действий, тем самым методом из части I. Отсюда и название.
Это тот самый код, который в статье «Первые тесты» просился под тест: чистая функция, у которой на одинаковый вход всегда одинаковый выход. Отсюда же и правила, которые редьюсер обязан соблюдать.
| Чего в редьюсере не делают | Куда это переносят |
|---|---|
Меняют пришедшее состояние: state.items.push(...) | Возвращают новый объект — как в статье «Объекты и массивы в состоянии» |
Ходят в сеть, ставят таймер, пишут в localStorage | Обработчик события, а если это синхронизация с внешним миром — эффект |
Читают Date.now() или Math.random() | Считают в обработчике и кладут результат в действие: { type: "added", id: crypto.randomUUID() } |
Последняя строка таблицы объясняет, почему действия иногда несут странную на вид нагрузку вроде готового идентификатора или времени. Всё непредсказуемое случается снаружи, в обработчике; в редьюсер приезжает уже посчитанное значение — и функция остаётся проверяемой.
В режиме разработки внутри <StrictMode> React намеренно вызывает редьюсер дважды на одно действие: нечистый редьюсер выдаёт себя сразу. В песочнице учебника React стоит отладочной сборкой, но без него, поэтому удвоения вы не увидите.
Мутация в редьюсере ломается не там, где её ждут. Ошибки не будет, массив честно вырастет — просто React сравнит старое состояние с новым через Object.is, увидит тот же объект и решит, что перерисовывать нечего.
Знакомая картина: экран догоняет данные по чужому поводу — ровно как переменная модуля из статьи про подъём состояния. Возвращайте новый объект, и вопрос снимается.
Редьюсер не «правильнее» useState — он про другую ситуацию. Одно независимое значение (раскрыт ли список, что набрано в поле, какая вкладка активна) редьюсером не улучшить: вместо одной строки появятся тип, функция и switch, а взамен не будет ничего. Смысл появляется там, где начинаются переходы.
| Что в компоненте | Чем брать | Почему |
|---|---|---|
| Одно значение, меняется само по себе | useState | Редьюсер добавит три конструкции и ни одной новой гарантии |
| Одно событие меняет два-три значения по правилу | useReducer | Правило записано один раз, а не в каждом обработчике |
| Следующее состояние зависит от текущего: нельзя ответить дважды, нельзя уйти назад с первого шага | useReducer | Проверка живёт там, где состояние всегда актуально, — внутри редьюсера |
| Обработчики разъехались по дереву компонентов | useReducer | Вниз уезжает один dispatch вместо пяти колбэков |
| Переходы хочется покрыть тестами | useReducer | Чистую функцию проверяют без React и без браузера |
| Значение вычисляется из другого состояния | ни то ни другое | Его вообще не хранят — статья «Как выбирать структуру состояния» |
Четвёртая строка стоит отдельного примера. В статье «Подъём состояния» вниз уезжали функции-колбэки: onAdd, onShow, onChange. Когда их становится пять, список пропсов начинает выглядеть как оглавление. С редьюсером вниз уходит один dispatch, а что именно он умеет — сказано в типе Action.
Тип пропса — React.Dispatch<Action>, и это не просто «какая-то функция»: ребёнок может объявить только одно из перечисленных действий, а с чужим действием его не пропустит компилятор. При этом права менять состояние как попало у ребёнка нет — как и раньше, он лишь сообщает наверх. Пробрасывать dispatch через три этажа так же скучно, как любой другой пропс, — этим займётся следующая статья, про контекст.
Если начальное состояние нужно вычислить — разобрать сохранённые ответы, построить массив по числу вопросов, — вычисление передают третьим аргументом: useReducer(reducer, arg, init). React вызовет init(arg) один раз, при первой отрисовке. Тот же приём, что и ленивая начальная функция у useState.
- Три useState, которые меняет одно событие, — это не три значения, а один переход, размазанный по обработчикам.
- Редьюсер — чистая функция (состояние, действие) → состояние: он живёт вне компонента, ничего не знает про React и проверяется обычным вызовом.
- Действия описывают размеченным объединением из части III: метка
typeплюс своя нагрузка у каждого варианта — и компилятор проверяет каждыйdispatch. - switch по метке плюс присваивание в
neverв default: забытая ветка становится ошибкой сборки, а не молча потерянным действием. - Два dispatch подряд применяются оба: редьюсер получает состояние, накопленное очередью, — как функция-обновитель.
- useState берут для одного независимого значения; useReducer — когда переходов много, они зависят от текущего состояния или разъехались по дереву компонентов.
1/4В одном обработчике стоят два вызова dispatch({ type: "inc" }) подряд, а редьюсер на это действие возвращает count + 1. Каким станет счётчик после одного клика, если до клика был 0?
2/4В размеченное объединение Action добавили четвёртый вариант, а ветку в switch не дописали. В default стоит const forgotten: never = action. Что произойдёт?
3/4Редьюсер выполнил state.items.push(action.text) и вернул тот же state. Что увидит пользователь после dispatch?
4/4Форма отправляет заявку на сервер и показывает результат. Где место самому запросу?
Три useState в один редьюсер
Форма из двух шагов: адрес проверяется на «собаку», при удаче переходим на второй шаг. Кнопка «назад» возвращает на первый — и оставляет на экране ошибку с прошлой попытки. Переведите компонент на useReducer: состояние в один объект, действия { type: "submitted"; email: string } и { type: "back" }, проверка адреса — внутри редьюсера, разбор вариантов — полный, со страховкой в default.
Три значения складываются в один объект, два обработчика превращаются в два действия. Главное — куда переехала проверка адреса: она стала частью перехода, а не частью кнопки.
Теперь «назад» описан целиком, и ошибка с прошлой попытки уехать на первый шаг не может: об этом сказано в редьюсере один раз и для всех, кто пошлёт это действие. Обработчики стали одинаково скучными — каждый объявляет своё событие и больше ни за что не отвечает.
Ветка, которую компилятор не нашёл
В плеер неделю назад добавили действие muted, а ветку для него не написали. Компилятор молчит, потому что в default стоит return state: кнопка «без звука» просто ничего не делает. Замените тихий default на страховку через never, прочитайте диагностику и допишите ветку — громкость при muted становится нулевой.
Сначала меняем тихий default на страховку — и компилятор перестаёт молчать: «Тип { type: "muted"; } не может быть назначен для типа never». Назван ровно тот вариант, ветки для которого нет.
Ветка дописана, до default снова доезжает пустой тип, диагностика исчезла. Разница в цене ошибки: страховку показала первая же сборка после правки типа, а тихий default ждал бы жалобы пользователя «кнопка не работает».
Проверить редьюсер без React
Прогон сценария показывает дыру: один и тот же вопрос отвечен дважды, и очки начислены дважды. Почините редьюсер так, чтобы повторный ответ на уже отвеченный вопрос не менял ни выбор, ни счёт, — и убедитесь в этом тем же прогоном, без React и без кликов.
«На отвеченный вопрос отвечают один раз» — это условие о текущем состоянии, и место ему в редьюсере: здесь состояние заведомо актуально, а не взято из снимка обработчика.
Прогон подтверждает: второй ответ не изменил ни выбор, ни счёт. Ни клика, ни браузера для этой проверки не понадобилось — вся логика экрана живёт в функции, у которой есть вход и выход.