State Management
Redux: паттерны и когда оправдан
В админке список из тысяч пользователей хранится как массив. Чтобы обновить одного по id, приходится искать его перебором и пересобирать массив через map. Чтобы найти по id для отрисовки строки, снова перебор. С ростом списка эти операции по всему коду дают линейный поиск там, где нужен доступ по ключу. createEntityAdapter решает это нормализацией: данные хранятся как словарь по id плюс массив порядка, доступ по ключу становится прямым, а готовые редьюсеры и селекторы избавляют от ручной возни с массивом.
- Большие списки сущностей: пользователи, заказы, сообщения, где нужен быстрый доступ и обновление по id
- Нормализованные данные с отношениями: посты ссылаются на авторов по id, без дублирования объектов
- Своё middleware: логирование action, отправка аналитики, синхронизация части состояния в localStorage
- Отладка через devtools с путешествием во времени: перемотка по action для воспроизведения бага
- Крупные приложения со сложным потоком, где предсказуемость Redux окупает его обвязку
Предварительные знания
- createSlice и Immer: где createEntityAdapter подключает свои редьюсеры
- Селекторы и createSelector: адаптер отдаёт готовые мемоизированные селекторы
- Однонаправленный поток и чистые редьюсеры как основа для middleware и devtools
createEntityAdapter и нормализация
Нормализация это хранение коллекции как словаря сущностей по id плюс отдельного массива ids, задающего порядок. Доступ к сущности по id становится прямым обращением к ключу вместо перебора массива. createEntityAdapter создаёт такую структуру и сразу даёт набор иммутабельных редьюсеров для типовых операций и набор мемоизированных селекторов для чтения.
Состояние адаптера имеет форму с двумя полями: ids это массив идентификаторов в нужном порядке, entities это словарь от id к сущности. Редьюсеры addOne, setAll, updateOne, removeOne обновляют обе части согласованно и иммутабельно через Immer. getSelectors отдаёт готовые мемоизированные selectAll, selectById и selectIds, поэтому ручной поиск по массиву исчезает.
| Операция | Плоский массив | Адаптер сущностей |
|---|---|---|
| Найти по id | items.find, перебор | entities[id], прямой доступ |
| Обновить по id | map с проверкой id | updateOne({ id, changes }) |
| Удалить по id | filter по id | removeOne(id) |
| Получить все по порядку | сам массив | selectAll, мемоизирован |
Нормализация особенно ценна, когда одни и те же сущности встречаются в разных местах. Если посты ссылаются на авторов по id, а не вкладывают объект автора, то правка автора в одном месте словаря отражается везде. Это убирает рассинхрон копий и дублирование данных, типичные для денормализованного дерева.
Что даёт нормализация через createEntityAdapter по сравнению с хранением коллекции плоским массивом?
Своё middleware
Middleware это перехватчик, стоящий между dispatch и reducer. Каждый отправленный action проходит через цепочку middleware до того, как попадёт в reducer. Это место для сквозных задач, которые не относятся к самому переходу состояния: логирование action, отправка аналитики, синхронизация части состояния в localStorage, перехват определённых action для побочных эффектов.
Форма middleware это тройная стрелка: store, затем next, затем action. Вызов next(action) передаёт action дальше по цепочке, в итоге к reducer. Код до next выполняется перед изменением состояния, код после next когда новое состояние уже доступно через store.getState. Пропуск вызова next остановит action, но это редкий случай, обычно его пробрасывают дальше.
Middleware это правильное место для побочных эффектов, а редьюсер нет. Редьюсер обязан оставаться чистым, иначе ломаются devtools и предсказуемость. Запрос к сети, запись в localStorage, логирование, отправка метрики идут в middleware или в thunk, но не внутрь редьюсера. Это прямое следствие принципа чистоты редьюсеров.
RTK предлагает createListenerMiddleware как декларативную альтернативу написанию middleware вручную. В нём заводят слушателей, реагирующих на конкретные action или изменения состояния, и выполняют побочные эффекты в их обработчиках. Для большинства задач (синхронизация, реакция на action) это удобнее голого тройного middleware.
Где правильно делать побочный эффект вроде записи части состояния в localStorage?
Devtools и путешествие во времени
Redux DevTools показывают список всех отправленных action, состояние после каждого и разницу между шагами. Главная их способность это путешествие во времени: перемотка состояния к любому прошлому action и обратно. Это возможно ровно потому, что соблюдены принципы Redux. Всё состояние в одном store, а редьюсеры чисты, поэтому любую точку истории можно восстановить, проиграв последовательность action заново.
Практическая ценность в воспроизводимости багов. Лог action это точная последовательность того, что произошло. Её можно сохранить из сессии пользователя и проиграть у себя, получив то же конечное состояние. Без чистоты редьюсеров и единого store такой детерминизм недостижим: побочные эффекты в переходе сделали бы повтор непредсказуемым.
configureStore включает интеграцию с Redux DevTools по умолчанию в режиме разработки. Поэтому весь store, включая нормализованные slice и кэш RTK Query, виден в одной панели с историей action. Это и есть та отладочная мощь, ради которой во многом и держат строгий поток Redux.
Что делает возможным путешествие во времени в Redux DevTools?
Когда Redux оправдан, а когда избыточен
Redux окупает свою обвязку там, где предсказуемость общего состояния стоит дороже краткости кода. Это крупные приложения со сложным переплетённым потоком, где одно изменение влияет на многое, командная разработка, где важно единое понимание изменений, и требование трассируемости и воспроизведения багов. В таких условиях единый store, строгий поток и devtools с перемоткой дают реальную отдачу.
- Redux оправдан — Крупное приложение, сложный общий поток, много разработчиков, нужны трассируемость, история action и devtools с перемоткой
- Redux избыточен — Маленькое или локальное состояние, простой клиентский стейт, пара компонентов. Обвязка не окупается, проще лёгкий стор или Context
Для маленького или простого клиентского состояния та же обвязка становится издержкой без отдачи. Открытая модалка, тема, выбранный таб не нуждаются в строгом потоке и истории action. Здесь уместнее лёгкий стор вроде Zustand или встроенный Context. Серверные данные при этом в любом случае держат не в ручных slice, а в RTK Query или TanStack Query.
Полезное правило выбора: начинать с локального состояния и поднимать его только по необходимости. Серверное состояние в query-слой. Простое общее клиентское в лёгкий стор. Redux выбирают осознанно под крупный сложный поток, а не по умолчанию. Тогда его строгость работает на проект, а не превращается в накладные расходы.
В каком сценарии обвязка Redux окупается лучше всего?
Связь с другими темами
Паттерны опираются на slice и селекторы и задают границу применимости Redux:
- Redux Toolkit: slices и Immer — createEntityAdapter генерирует редьюсеры для slice и иммутабельно обновляет нормализованное состояние через Immer
- Селекторы и reselect — Адаптер сразу отдаёт мемоизированные селекторы selectAll и selectById поверх нормализованного состояния
- Zustand — Там, где Redux избыточен, лёгкий стор закрывает клиентское состояние без adapter, middleware и тегов
Итог
- createEntityAdapter нормализует данные: словарь сущностей по id плюс массив ids порядка вместо плоского массива
- Адаптер даёт готовые редьюсеры (addOne, upsertMany, updateOne, removeOne) и мемоизированные селекторы (selectAll, selectById)
- Своё middleware перехватывает каждый action между dispatch и reducer: логи, аналитика, синхронизация, не трогая редьюсеры
- Devtools с путешествием во времени работают благодаря чистым редьюсерам и единому store: историю action можно перематывать
- Redux оправдан в крупных приложениях со сложным общим потоком, командной разработкой и потребностью в трассируемости
- Для маленького локального или простого клиентского состояния обвязка Redux избыточна, и уместнее лёгкий стор
Связанные уроки
- sm-12-rtk-slices — Паттерны строятся на slice: createEntityAdapter генерирует редьюсеры и селекторы для того же slice
- sm-14-rtk-selectors — createEntityAdapter отдаёт готовые мемоизированные селекторы, и понимать их нужно из урока про reselect
- sm-17-zustand — Когда Redux избыточен, лёгкие сторы вроде Zustand закрывают то же клиентское состояние меньшим кодом