useReducer и размеченные объединения

общее состояние≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пять useState в компоненте — не преступление, пока каждое значение живёт само по себе. Беда начинается, когда одно событие обязано изменить сразу три: правило «новый вопрос — пустой выбор» приходится повторять в каждом обработчике, и однажды его не повторяют. useReducer собирает все переходы в одну функцию, а обработчику оставляет одну строку — сказать, что произошло. Описывать эти сообщения вы уже умеете: это размеченное объединение из части III.

Разделы темы:·

Квиз ниже держит три значения: номер вопроса, выбранный вариант и счёт. По отдельности они безобидны, но между ними есть правило: как только меняется вопрос, выбор обязан обнулиться. Записать это правило негде — оно живёт в обработчиках, и каждый новый обработчик обязан о нём помнить. Пример нажимает «вариант 2», а потом «заново». Предскажите последнюю строку вывода.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Вопрос 1, выбран 2, очки 0» — состояние, которого не бывает: вариант выбран, а очков за него нет. restart честно сбросил то, о чём помнил его автор, — номер вопроса и счёт, — а picked остался от прошлой попытки. Форма состояния тут ни при чём: три значения и правда независимы, и статья о структуре состояния к ним не придерётся. Противоречие принёс переход.

Посмотрите, где живёт правило «новый вопрос — пустой выбор»: в next оно есть, в restart его нет, и связывает эти два места только внимательность. Появится третий переход — «пропустить», «вернуться назад», «выйти по таймеру» — и правило придётся написать в третий раз. Каждый обработчик знает свою часть истории, а всей истории не знает никто.

Признак болезни простой: в одном обработчике три сеттера, в соседнем — те же три, но одного не хватает.

Переход на useReducer — это три перестановки. Три useState собираются в один объект состояния. Обработчики перестают менять состояние и начинают объявлять о событии: «выбрали вариант», «дальше», «заново». А все переходы переезжают в одну функцию — редьюсер: он получает текущее состояние и объявленное действие, а возвращает следующее состояние.

Форма функции знакома по reduce из части I: накопитель плюс очередной элемент дают новый накопитель. Здесь накопитель — состояние экрана, а элементы — то, что делает пользователь.

Сам вызов похож на useState: const [state, dispatch] = React.useReducer(quizReducer, initial). Слева приезжают текущее состояние и dispatch — единственный способ что-то в этом состоянии изменить; справа — редьюсер и то, с чего всё начинается. Тот же квиз, та же кнопка «заново».

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

И ещё одно различие, которое легче увидеть, чем объяснить. Помните два setCount(count + 1) подряд из статьи про снимок? Ниже в одном обработчике стоят два dispatch и два setByState. Предскажите обе цифры.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два dispatch дали двойку, два setByState(byState + 1) — единицу. setByState(byState + 1) — это заказ на конкретное число, посчитанное из снимка; второй такой заказ просто повторяет первый. dispatch заказывает не число, а действие: React складывает действия в ту самую очередь из статьи про обновитель и прогоняет по ней редьюсер, подавая ему уже накопленное состояние. Функция-обновитель setCount(n => n + 1) делает то же самое — редьюсер и есть общий обновитель, вынесенный из компонента и знающий обо всех переходах сразу.

dispatch не меняется между отрисовками: это одна и та же функция от первого рендера до размонтирования.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип Action из примера выше — то самое размеченное объединение из части III: общее поле-метка type с литеральным типом, а у каждого варианта своя нагрузка. «Выбрали вариант» несёт номер, «дальше» и «заново» не несут ничего — и это не пожелание в комментарии, а часть типа.

В доске из части II действие ездило строкой в data-action, и опечатку в ней ловил только клик — если вы догадались нажать именно ту кнопку. Здесь мимо типа не проехать. Три вызова ниже сломаны по-разному; прочитайте, что компилятор скажет о каждом.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор отвечает на все три. «Аргумент типа { 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 доезжает пустой тип и присваивание законно. Если хоть один забыт, туда приедет он.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип { type: "restart"; } не может быть назначен для типа never» — и номер строки той самой заглушки в default. Компилятор не сказал «где-то не хватает ветки»: он назвал забытый вариант по имени. Допишите case "restart" — и диагностика исчезнет сама, потому что до default снова начнёт доезжать пустой тип.

Вторая строка вывода показывает, что было бы без этой страховки: restart не изменил ничего. Пользователь жмёт кнопку, ничего не происходит, в консоли пусто — и искать нечего, потому что ошибки нет. Забытая ветка не падает, она молчит; тем и опасна.

Переменная forgotten нужна только компилятору и в каждом редьюсере выглядит одинаково. Функция assertNever из части III делает то же самое, но добавляет вторую линию обороны — на случай, если действие придёт откуда-то, где типов не было: из JSON.parse, из старой вкладки, из чужого кода.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

А вот default: return state — заманчивая, но плохая привычка: она глушит и опечатки, и забытые ветки. Возвращать состояние без изменений редьюсер должен осознанно, в именованной ветке («ответ на уже отвеченный вопрос»), а не по остаточному принципу.

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

Проверка перехода не требует ни кнопки, ни браузера: подайте состояние и действие — получите состояние. А целый сценарий пользователя — это reduce по массиву действий, тем самым методом из части I. Отсюда и название.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это тот самый код, который в статье «Первые тесты» просился под тест: чистая функция, у которой на одинаковый вход всегда одинаковый выход. Отсюда же и правила, которые редьюсер обязан соблюдать.

Чего в редьюсере не делаютКуда это переносят
Меняют пришедшее состояние: state.items.push(...)Возвращают новый объект — как в статье «Объекты и массивы в состоянии»
Ходят в сеть, ставят таймер, пишут в localStorageОбработчик события, а если это синхронизация с внешним миром — эффект
Читают Date.now() или Math.random()Считают в обработчике и кладут результат в действие: { type: "added", id: crypto.randomUUID() }

Последняя строка таблицы объясняет, почему действия иногда несут странную на вид нагрузку вроде готового идентификатора или времени. Всё непредсказуемое случается снаружи, в обработчике; в редьюсер приезжает уже посчитанное значение — и функция остаётся проверяемой.

В режиме разработки внутри <StrictMode> React намеренно вызывает редьюсер дважды на одно действие: нечистый редьюсер выдаёт себя сразу. В песочнице учебника React стоит отладочной сборкой, но без него, поэтому удвоения вы не увидите.

Мутация в редьюсере ломается не там, где её ждут. Ошибки не будет, массив честно вырастет — просто React сравнит старое состояние с новым через Object.is, увидит тот же объект и решит, что перерисовывать нечего.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Знакомая картина: экран догоняет данные по чужому поводу — ровно как переменная модуля из статьи про подъём состояния. Возвращайте новый объект, и вопрос снимается.

Редьюсер не «правильнее» useState — он про другую ситуацию. Одно независимое значение (раскрыт ли список, что набрано в поле, какая вкладка активна) редьюсером не улучшить: вместо одной строки появятся тип, функция и switch, а взамен не будет ничего. Смысл появляется там, где начинаются переходы.

Что в компонентеЧем братьПочему
Одно значение, меняется само по себеuseStateРедьюсер добавит три конструкции и ни одной новой гарантии
Одно событие меняет два-три значения по правилуuseReducerПравило записано один раз, а не в каждом обработчике
Следующее состояние зависит от текущего: нельзя ответить дважды, нельзя уйти назад с первого шагаuseReducerПроверка живёт там, где состояние всегда актуально, — внутри редьюсера
Обработчики разъехались по дереву компонентовuseReducerВниз уезжает один dispatch вместо пяти колбэков
Переходы хочется покрыть тестамиuseReducerЧистую функцию проверяют без React и без браузера
Значение вычисляется из другого состоянияни то ни другоеЕго вообще не хранят — статья «Как выбирать структуру состояния»

Четвёртая строка стоит отдельного примера. В статье «Подъём состояния» вниз уезжали функции-колбэки: onAdd, onShow, onChange. Когда их становится пять, список пропсов начинает выглядеть как оглавление. С редьюсером вниз уходит один dispatch, а что именно он умеет — сказано в типе Action.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип пропса — React.Dispatch<Action>, и это не просто «какая-то функция»: ребёнок может объявить только одно из перечисленных действий, а с чужим действием его не пропустит компилятор. При этом права менять состояние как попало у ребёнка нет — как и раньше, он лишь сообщает наверх. Пробрасывать dispatch через три этажа так же скучно, как любой другой пропс, — этим займётся следующая статья, про контекст.

Если начальное состояние нужно вычислить — разобрать сохранённые ответы, построить массив по числу вопросов, — вычисление передают третьим аргументом: useReducer(reducer, arg, init). React вызовет init(arg) один раз, при первой отрисовке. Тот же приём, что и ленивая начальная функция у useState.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

  • Три 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.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три значения складываются в один объект, два обработчика превращаются в два действия. Главное — куда переехала проверка адреса: она стала частью перехода, а не частью кнопки.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь «назад» описан целиком, и ошибка с прошлой попытки уехать на первый шаг не может: об этом сказано в редьюсере один раз и для всех, кто пошлёт это действие. Обработчики стали одинаково скучными — каждый объявляет своё событие и больше ни за что не отвечает.

Ветка, которую компилятор не нашёл

В плеер неделю назад добавили действие muted, а ветку для него не написали. Компилятор молчит, потому что в default стоит return state: кнопка «без звука» просто ничего не делает. Замените тихий default на страховку через never, прочитайте диагностику и допишите ветку — громкость при muted становится нулевой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сначала меняем тихий default на страховку — и компилятор перестаёт молчать: «Тип { type: "muted"; } не может быть назначен для типа never». Назван ровно тот вариант, ветки для которого нет.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ветка дописана, до default снова доезжает пустой тип, диагностика исчезла. Разница в цене ошибки: страховку показала первая же сборка после правки типа, а тихий default ждал бы жалобы пользователя «кнопка не работает».

Проверить редьюсер без React

Прогон сценария показывает дыру: один и тот же вопрос отвечен дважды, и очки начислены дважды. Почините редьюсер так, чтобы повторный ответ на уже отвеченный вопрос не менял ни выбор, ни счёт, — и убедитесь в этом тем же прогоном, без React и без кликов.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«На отвеченный вопрос отвечают один раз» — это условие о текущем состоянии, и место ему в редьюсере: здесь состояние заведомо актуально, а не взято из снимка обработчика.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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