State Management
Селекторы и reselect
Компонент корзины должен показать сумму со скидкой: пройти по товарам, перемножить цены на количество, применить промокод. Если считать это прямо в компоненте на каждый рендер, тяжёлый перебор повторяется даже тогда, когда товары не менялись и поменялась лишь тема интерфейса. Хуже того, селектор, который на каждом вызове собирает новый массив, заставляет компонент перерисовываться без причины: ссылка новая, хотя данные те же. createSelector решает обе проблемы: он кэширует результат и пересчитывает его только при изменении входных кусков состояния.
- Сумма корзины со скидками: тяжёлый перебор товаров, который не должен повторяться на каждый рендер
- Отфильтрованные и отсортированные списки, где фильтр и сортировка выводятся из состояния
- Агрегаты для дашбордов: счётчики, группировки, статистика поверх большого массива
- Производные данные, разделяемые многими компонентами, чтобы вычисление было в одном месте
- Селекторы, скрывающие форму store, чтобы рефакторинг состояния не ломал компоненты
Предварительные знания
- createSlice и форма состояния в store: где что лежит
- useSelector и подписка компонента на кусок состояния
- Сравнение по ссылке и почему новый объект на каждый вызов вызывает ре-рендер
Селектор как функция чтения состояния
Селектор это функция, принимающая состояние и возвращающая часть его или производное от него значение. Смысл в инкапсуляции: компоненты не лезут в store напрямую через s.cart.items, а вызывают именованный селектор selectCartItems. Если форма состояния изменится при рефакторинге, поправить нужно только селектор, а все компоненты, которые им пользуются, останутся нетронутыми.
Такой простой селектор лишь достаёт кусок состояния и не выполняет вычислений. Он дёшев и не требует мемоизации: возврат той же ссылки на тот же items не вызовет лишний ре-рендер. Мемоизация нужна тогда, когда селектор не просто читает, а вычисляет новое значение - новый массив, объект или агрегат.
Разделение на простые и вычисляющие селекторы это основа всего подхода. Простые селекторы это входы: они читают конкретные куски store. Вычисляющие селекторы строятся поверх простых и комбинируют их результаты. Именно вычисляющие селекторы оборачивают в createSelector, чтобы кэшировать тяжёлую работу.
Зачем компоненту вызывать именованный селектор вместо прямого доступа s.cart.items?
Мемоизация через createSelector
createSelector из библиотеки reselect принимает один или несколько входных селекторов и финальную функцию-комбинатор. Он запоминает последние входные значения и последний результат. Пока входные селекторы возвращают те же значения, комбинатор не вызывается, и отдаётся закэшированный результат. Комбинатор пересчитывается только когда хотя бы один вход изменился по сравнению с прошлым разом.
Здесь входы это selectCartItems и selectPromoCode. Пока массив товаров и промокод не менялись по ссылке, тяжёлый reduce не выполняется повторно, даже если в другой части store что-то поменялось и компонент перерисовался. Это и есть мемоизация: дорогое вычисление кэшируется и привязывается к своим реальным зависимостям, а не к каждому изменению store.
createSelector сравнивает входы по ссылке, а не по содержимому. Поэтому важно, чтобы входные селекторы возвращали стабильные ссылки. Если входной селектор сам собирает новый массив на каждом вызове, мемоизация ломается: вход всегда новый, и комбинатор считается каждый раз. Иммутабельные обновления Immer как раз и дают нужную стабильность ссылок на неизменившиеся ветки.
Когда createSelector повторно запускает свою функцию-комбинатор?
Стабильные ссылки против лишних ре-рендеров
useSelector ре-рендерит компонент, когда возвращённое селектором значение изменилось. Сравнение по умолчанию идёт по ссылке. Селектор, который на каждом вызове собирает новый объект или массив, всегда отдаёт новую ссылку, поэтому компонент перерисовывается даже без реального изменения данных. Мемоизированный селектор отдаёт ту же ссылку, пока входы не менялись, и лишний ре-рендер не происходит.
Верхний селектор фильтрует на каждом вызове и возвращает новый массив, поэтому подписанный компонент перерисовывается при любом изменении store. Нижний оборачивает то же вычисление в createSelector: пока items не менялся, возвращается прежний отфильтрованный массив с той же ссылкой, и ре-рендер не запускается. Вычисление и стабильность ссылки решаются одним приёмом.
- Немемоизированный вычисляющий селектор — Новый массив или объект на каждый вызов. Ссылка всегда новая, компонент ре-рендерится при любом изменении store
- Мемоизированный селектор — Стабильная ссылка, пока входы те же. Компонент ре-рендерится только при реальном изменении производного значения
Правило простое: селектор, который только читает кусок состояния, мемоизировать не нужно. Селектор, который вычисляет новый массив, объект или агрегат, оборачивают в createSelector. Так стабильность ссылки гарантируется именно там, где без неё возникают лишние ре-рендеры.
Почему немемоизированный селектор с filter внутри вызывает лишние ре-рендеры?
Селекторы с аргументами и границы кэша
У дефолтного createSelector кэш размером в один результат: он помнит только последний набор входов. Это создаёт ловушку для селекторов, принимающих аргумент, например selectTodoById с разными id. Если два компонента вызывают такой селектор с разными id поочерёдно, кэш постоянно сбрасывается, и мемоизация перестаёт работать. Каждый вызов с новым id вытесняет предыдущий результат.
Решение это селектор-фабрика: функция, создающая отдельный экземпляр селектора со своим кэшем для каждого использования. Тогда у каждого компонента собственный кэш, и чередование аргументов между компонентами не сбрасывает чужой результат. RTK также предлагает createSelector с настраиваемым размером кэша через memoizeOptions, но базовый случай чаще решается именно фабрикой на компонент.
reselect встроен в Redux Toolkit и отдельной установки не требует: createSelector импортируется прямо из @reduxjs/toolkit. Тот же механизм лежит в основе селекторов, которые автоматически генерирует createEntityAdapter для нормализованных данных. Так что мемоизация это не опция на потом, а штатный слой чтения над store.
Почему один общий createSelector с аргументом id плохо работает, когда разные компоненты передают разные id?
Связь с другими темами
Селекторы это слой чтения над store, к которому примыкают соседние темы:
- Redux Toolkit: slices и Immer — Slice формирует состояние, селекторы его читают и выводят производные значения, скрывая форму store
- Redux: паттерны и когда оправдан — createEntityAdapter из RTK сам отдаёт готовые мемоизированные селекторы для нормализованных сущностей
Итог
- Селектор это функция (state) к производному значению: он инкапсулирует форму store и держит логику чтения в одном месте
- createSelector из reselect мемоизирует результат: пересчёт происходит только при изменении входных селекторов
- Мемоизация даёт стабильную ссылку на результат, и компонент не ре-рендерится, пока входы не изменились
- Простые селекторы читают кусок состояния, составные комбинируют их и считают производное значение
- reselect встроен в Redux Toolkit, отдельная установка не нужна
- Дефолтный кэш reselect хранит один последний результат, поэтому селекторы с аргументами требуют отдельного приёма
Связанные уроки
- sm-12-rtk-slices — Селекторы читают состояние, которое формирует slice: без понимания формы store и редьюсеров их не написать
- sm-16-redux-patterns — createEntityAdapter генерирует готовые селекторы для нормализованных данных, опираясь на тот же createSelector