State Management
Redux: ядро
В корзине интернет-магазина один и тот же товар хранится в трёх местах: в превью корзины в шапке, на странице оформления и в локальном состоянии карточки товара. Пользователь меняет количество на странице оформления, а шапка показывает старое число. Каждый компонент держит свою копию и обновляет её по-своему. Redux отвечает на это так: пусть есть одно дерево состояния на всё приложение, менять его можно только через описанные действия, а сама замена это чистая функция от старого состояния и действия. Тогда в любой момент понятно, что в состоянии, как оно туда попало и кто его изменил.
- Корзина и каталог в e-commerce: цена, количество, скидки в одном дереве состояния, видимом из любого компонента
- Сложные дашборды с фильтрами, где фильтр в одном месте должен синхронно влиять на несколько панелей
- Приложения с историей действий: undo/redo через журнал отправленных action
- Команды, которым важна воспроизводимость бага: лог action позволяет повторить точную последовательность
- Крупные приложения, где десятки разработчиков должны одинаково понимать, как меняется состояние
Предварительные знания
- Flux: однонаправленный поток action - dispatcher - store - view
- Редьюсер как чистая функция (state, action) и возврат нового состояния
- Иммутабельное обновление: создание нового объекта вместо мутации старого
Откуда взялся Redux
Redux представили Дэн Абрамов и Эндрю Кларк в 2015 году на конференции React Europe. Идея выросла из архитектуры Flux от Facebook, но упростила её до одного store и чистых редьюсеров вместо множества store с обратными вызовами. Толчком стала разработка инструмента отладки с путешествием во времени: чтобы перематывать состояние вперёд и назад, переходы должны быть детерминированными чистыми функциями, а всё состояние храниться в одном месте. Это и зафиксировало три принципа. Redux быстро стал стандартом управления состоянием в React-экосистеме на годы вперёд, пока позже Redux Toolkit не убрал большую часть его ручной обвязки.
Store, action, reducer, dispatch
В Redux четыре понятия работают вместе. Store это единый объект, хранящий всё дерево состояния приложения. Action это обычный объект, описывающий, что произошло: у него есть поле type и при необходимости полезная нагрузка. Reducer это чистая функция, которая по текущему состоянию и пришедшему action вычисляет новое состояние. Dispatch это способ отправить action в store, чтобы запустить пересчёт состояния через reducer.
Обратить внимание стоит на default-ветку: при неизвестном action reducer обязан вернуть состояние без изменений. Это важно, потому что store при инициализации прогоняет служебный action, и reducer должен отдать начальное значение. Полезная нагрузка лежит в поле payload, а не разбросана по корню action: так store devtools и логи остаются единообразными.
| Понятие | Что это | Роль |
|---|---|---|
| Store | Объект с деревом состояния | Хранит состояние, отдаёт его через getState |
| Action | Объект { type, payload? } | Описывает что произошло, без логики |
| Reducer | Чистая функция (state, action) | Вычисляет новое состояние |
| Dispatch | Метод store | Отправляет action и запускает reducer |
Что обязан вернуть reducer, если пришёл action с неизвестным ему type?
Однонаправленный поток данных
Главная архитектурная идея Redux в том, что данные текут в одну сторону. Компонент отправляет action через dispatch. Store передаёт текущее состояние и этот action в reducer. Reducer возвращает новое состояние. Store сохраняет его и оповещает подписчиков, и UI перерисовывается с новыми данными. Замкнуть круг можно только новым action. Нет пути, по которому компонент менял бы состояние напрямую, минуя этот цикл.
Ценность однонаправленности в предсказуемости. Раз состояние меняется только через action, любое изменение оставляет след в виде отправленного action. Можно записать всю их последовательность в лог, повторить её и получить ровно то же конечное состояние. Двунаправленная связь, где UI и состояние правят друг друга в обе стороны, такой гарантии не даёт: источник изменения теряется.
Именно однонаправленный детерминированный поток делает возможным devtools с путешествием во времени. Инструмент хранит список action и начальное состояние, а раз reducer чист, любую точку истории можно восстановить, проиграв action заново. Без чистоты редьюсера и единого store это не работает.
Почему однонаправленный поток данных в Redux упрощает отладку?
Три принципа Redux
Redux держится на трёх правилах, и из них выводится всё остальное. Первое: единый источник истины - всё состояние приложения хранится в одном store как одно дерево объектов. Второе: состояние только для чтения - единственный способ его изменить это отправить action, прямое присваивание полям состояния запрещено. Третье: изменения описываются чистыми редьюсерами - функция перехода не имеет побочных эффектов и возвращает новое состояние, не мутируя старое.
- Единый источник истины: одно дерево состояния в одном store на всё приложение
- Состояние только для чтения: изменить его можно лишь через dispatch(action)
- Изменения через чистые редьюсеры: (state, action) без побочных эффектов и без мутации входа
Нарушение принципа чистоты ломает Redux тихо. Если редьюсер сделает state.items.push(...) и вернёт тот же объект, ссылка на состояние не изменится. Многие оптимизации сравнивают состояние по ссылке, поэтому UI может не обновиться, а devtools покажет неверную историю. Поэтому в чистом Redux каждое изменение это создание нового объекта.
Ровно эта ручная иммутабельность через спред-операторы и есть та боль, которую позже убирает Redux Toolkit. В RTK редьюсер можно писать в мутирующем стиле, а Immer под капотом превращает это в корректное создание нового состояния. Но понимать исходное правило важно: внизу всё равно иммутабельность.
Какой из трёх принципов Redux нарушает редьюсер, который делает state.items.push(item) и возвращает тот же объект state?
Зачем Redux появился
До Redux общее состояние в крупных приложениях растекалось по компонентам и сервисам. Одни и те же данные жили в нескольких местах и расходились. Источник изменения было трудно найти: состояние мог поменять любой обработчик в любом компоненте. Архитектура Flux от Facebook предложила однонаправленный поток, но оставляла несколько store с перекрёстными зависимостями. Redux упростил это до одного store и чистых редьюсеров.
- Состояние без Redux — Данные дублируются по компонентам, меняются откуда угодно, расходятся между собой. Найти источник изменения и воспроизвести баг тяжело
- Состояние с Redux — Одно дерево, изменения только через action, переходы чисты. Любое изменение прослеживается, история повторяема, баг воспроизводится по логу
Redux покупает предсказуемость ценой обвязки. За неё платят явными типами action, отдельными редьюсерами и ручной иммутабельностью. Этот объём кода и стал главной критикой раннего Redux. Ответ на критику это Redux Toolkit: он сохраняет ядро и три принципа, но генерирует action и reducer из описания slice и берёт иммутабельность на себя через Immer.
Важно держать в голове границу применимости. Redux оправдан там, где предсказуемость общего состояния стоит дороже краткости: крупные приложения, сложный поток, командная разработка, требование воспроизводить баги. Для маленького локального состояния его обвязка избыточна, и это тема отдельного урока про паттерны и уместность Redux.
Какую проблему Redux ставил во главу угла при своём появлении?
Связь с другими темами
Ядро Redux это фундамент, поверх которого строится всё остальное:
- Flux и редьюсер — Redux это упрощение Flux: один store вместо многих и чистый редьюсер как единственный способ перехода
- Redux Toolkit: slices и Immer — RTK сохраняет это ядро, но прячет ручной boilerplate: createSlice генерирует action и reducer за разработчика
Итог
- Redux это контейнер состояния с однонаправленным потоком: action описывает что произошло, reducer вычисляет новое состояние, store хранит его
- Единственный способ изменить состояние это отправить action через dispatch, прямая мутация запрещена
- Reducer это чистая функция (state, action) без побочных эффектов: на одинаковый вход всегда одинаковый выход
- Три принципа: единый источник истины (один store), состояние только для чтения, изменения только через чистые редьюсеры
- Детерминированность переходов это то, что делает возможными devtools с путешествием во времени и воспроизведение багов по логу action
- Redux появился как упрощение Flux и зафиксировал предсказуемость состояния как главную ценность
Связанные уроки
- sm-07-flux-reducer — Flux и идея редьюсера это прямой предшественник Redux: store-dispatcher-view и чистая функция перехода состояния
- sm-12-rtk-slices — Поняв ядро Redux, следующий шаг это Redux Toolkit, который убирает ручной boilerplate этого ядра