React

useReducer: сложное состояние

Корзина интернет-магазина: добавить товар, убрать, изменить количество, применить промокод, пересчитать сумму со скидкой и доставкой. Десяток разных переходов, и многие из них завязаны друг на друга - количество влияет на сумму, промокод на скидку, скидка на итог. Держать это в куче отдельных useState и синхронизировать вручную - прямой путь к рассинхрону. useReducer собирает все переходы в одну функцию, где логика читается целиком.

  • Корзина: добавление, удаление, изменение количества и промокоды как набор именованных действий над одним состоянием
  • Сложные формы: пошаговые мастера, валидация полей и взаимозависимые поля, где один ввод влияет на доступность других
  • Undo/redo редакторы: история действий естественно ложится на reducer, где каждое действие - чистый переход
  • Игровое состояние: ход, очки, фишки и фазы игры меняются согласованно через диспетчеризацию действий
  • Redux и Zustand: вся индустрия state-менеджмента построена на той же идее reducer - чистый переход (state, action) к новому state

Предварительные знания

  • useState: хранение состояния и вызов повторного рендера через сеттер
  • Чистые функции: одинаковый вход даёт одинаковый выход, без побочных эффектов
  • Иммутабельное обновление объектов и массивов через spread и map/filter

dispatch и reducer

useReducer разделяет две вещи: что произошло и как от этого меняется состояние. Компонент только сообщает о событии через dispatch, передавая объект-действие. Вся логика обновления живёт в reducer - функции, которая принимает текущее состояние и действие, а возвращает новое состояние. Хук возвращает пару: текущее состояние и функцию dispatch. Когда вызывается dispatch, React прогоняет reducer и перерисовывает компонент с новым состоянием.

Обработчики событий стали тонкими: они лишь объявляют намерение - 'произошёл increment'. Они не знают, как именно меняется состояние. Это знание сосредоточено в reducer. Когда переходов становится много, такое разделение делает поведение читаемым: чтобы понять все возможные изменения состояния, достаточно прочитать один reducer, а не собирать логику по десятку обработчиков.

Действие принято описывать как 'что случилось' с точки зрения пользователя, а не как 'что сделать с состоянием'. Например, тип 'added_to_cart' читается лучше, чем 'set_items'. Такой стиль делает историю действий понятной и хорошо ложится на отладку и логирование.

Как распределяются роли между dispatch и reducer?

Когда useReducer лучше useState

useState отлично подходит для независимых кусочков состояния: одно поле, один флаг, один счётчик. useReducer начинает выигрывать, когда состояний несколько и они взаимозависимы - изменение одного должно согласованно менять другие. Если в обработчиках появляется логика вида 'обновить A, затем пересчитать B на основе A, и не забыть C', это сигнал, что переходы стоит собрать в reducer.

  • useState: логика размазана — Несколько setItems, setTotal, setDiscount в каждом обработчике. Легко забыть пересчитать total после изменения количества. Согласованность держится на дисциплине разработчика
  • useReducer: логика в одном месте — Один reducer обрабатывает действие и вычисляет все связанные поля разом. Согласованность гарантирована: после каждого действия состояние целостно по построению

Здесь items и total всегда согласованы: total пересчитывается в той же ветке, что меняет items. Невозможно изменить корзину и забыть обновить сумму - они меняются вместе по построению. С набором отдельных useState такую гарантию пришлось бы поддерживать вручную в каждом обработчике.

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

В каком случае useReducer предпочтительнее набора useState?

Reducer обязан быть чистым

Reducer должен быть чистой функцией: на одни и те же state и action он обязан возвращать один и тот же результат, не выполнять побочных эффектов и не мутировать аргументы. Никаких fetch, записи в localStorage, вызовов случайных чисел или изменения текущего state прямо в теле. Reducer только вычисляет следующее состояние из текущего и действия. Всё остальное - подписки, запросы, логи - живёт в обработчиках событий или эффектах.

Самая частая ошибка - мутация состояния вместо создания нового объекта. state.items.push(item); return state; не сработает: ссылка на объект та же, и React по Object.is решит, что ничего не изменилось, и не перерисует. Нужно вернуть новый объект: return { ...state, items: [...state.items, item] }.

Чистота reducer даёт три выгоды. Во-первых, его легко тестировать в изоляции: подал state и action, проверил результат, никакого React не требуется. Во-вторых, поведение предсказуемо и воспроизводимо - на этом строятся time-travel отладка и логирование действий. В-третьих, React может безопасно вызывать reducer повторно в Strict Mode для проверки, и чистая функция это переживёт без последствий.

Та же модель чистого редьюсера лежит в основе Redux и других менеджеров: состояние меняется только через действия, переход всегда чист. Освоив useReducer, разработчик уже понимает ядро этих библиотек - отличие в основном в том, что стор глобальный, а не локальный для компонента.

Почему reducer нельзя мутировать переданное состояние, например через state.items.push()?

Связь с другими темами

Урок про управление сложным состоянием. Рядом:

  • useState — Точка отсчёта: useReducer берут, когда нескольких useState становится недостаточно
  • useContext — Reducer часто раздают через контекст: потомки получают state и dispatch без проброса props
  • Модель рендера — dispatch инициирует новый рендер так же, как сеттер useState

Итог

  • useReducer возвращает пару [state, dispatch]: dispatch отправляет объект-действие, а reducer вычисляет новое состояние
  • Reducer - это чистая функция (state, action) к новому state: тот же вход даёт тот же выход, без мутаций и побочных эффектов
  • useReducer выигрывает над useState, когда переходов много и они взаимозависимы: логика собрана в одном месте, а не размазана по обработчикам
  • Действие - это объект с полем type и при необходимости данными (payload); type описывает, что произошло, а не как менять состояние
  • Reducer обязан возвращать новый объект состояния, а не мутировать старый, иначе React не заметит изменения

Связанные уроки

  • rc-06-usestate — useReducer - альтернатива useState для сложных случаев, поэтому базовая модель состояния нужна заранее
  • rc-14-usecontext — Связка reducer + контекст раздаёт сложное состояние и dispatch по дереву без prop drilling
  • rc-10-render-mental-model — dispatch вызывает повторный рендер так же, как сеттер useState, и это понятнее с моделью рендера
useReducer: сложное состояние

0

1

Войти