Зачем нужны селекторы?

Чтобы ответить на этот вопрос, нужно разобраться в том, что вообще из себя представляет редакс.

Редакс - Single-store стейт-менеджер, в котором к тому же принято группировать данные по объектам. Примерно так же, как в стейте классовых компонентов в реакте. То есть, это совершенная противоположность атомарного подхода, которому, к примеру, следуют многие хуки реакта или Multi-store стейт-менеджеры (например Effector, где селекторы не нужны по определению - достаточно сторов).

Помимо этого, объекты, которыми оперируют редьюсеры в редаксе, не являются какой-то полноценной сущностью - это просто кусок чистых данных. Противоположность - MobX, где в сторах могут быть экшны и компьютеды. Кстати, последние как раз являются заменой селекторов в MobX.

В общем, раз в редаксе нет возможностей ни эффектора, ни MobX, нам лишь остается напрямую обращаться к данным, начиная с самого верха. Это превращается в нечто вроде state.foo.bar.baz. А если перед этим еще нужно произвести какие-то вычисления с участием других значений из стора?

Собственно по этим причинам, там и напрашивается какой-то отдельный слой, который возьмет на себя роль получения, комбинирования и преобразования данных. Этим слоем стали селекторы.

// src/features/cart/model/selectors.js

// Селектор стейта корзины покупок
export const all = state => state.cart

// Массив покупок, добавленных в корзину
export const items = state => all(state).items

// Бонусы, которые будут получены за покупку
export const collectedBonuses = state => all(state).collectedBonuses

/*
 * Суммарная стоимость покупок
 * Здесь используется функция createSelector из библиотеки reselect,
 * в данном случае она нужна чтобы не делать вычисления лишний раз
 * Подробнее такие селекторы будут рассмотрены ниже
 */
export const totalAmount = createSelector(
  items,
  items => items.reduce((acc, item) => {
    const { price, count } = item
    return acc + price * count
  }, 0)
)

Основные правила использования селекторов

Чтобы извлечь максимальную пользу от селекторов, нужно соблюдать несколько правил.

1. Не писать функции-селекторы прямо в компонентах

const Counter = () => {
  // плохо!
  const count = useSelector(state => state.counter.count)
}

Почему?

  • Если в нескольких местах нужно получить одни и те же данные - код начинает дублироваться, его становится тяжело поддерживать.

  • При каждом рендере - новая функция.
    И нет, это не относится к секте "плодить функции в компоненте плохо".
    Просто селектор будет вызываться при каждом рендере, а не только когда обновились данные в сторе.
    Вот эта логика в исходниках useSelector.

  • Логика получения данных из структуры стора находится внутри компонента.
    Но зачем компоненту знать об этом?

  • Ну и это просто-напросто неудобно.

И что делать?

Смотреть следующий пункт =)

2. Выносить селекторы в отдельный слой

Вы можете располагать этот слой в различных местах, в зависимости от используемой вами архитектуры. Но это всегда место, близкое к слайсу (редьюсеру, экшнам).

Например, при использовании методологии feature-sliced (кстати рекомендую) с чистым редаксом (а это не рекомендую) - выглядеть это будет как-то так:

features
 ? counter
   ? model
     ? types.js
     ? reducer.js
     ? actions.js
     ? selectors.js <- вот тут
     ? index.js

Также важно, чтобы селекторы были в отдельном файле, а не там же, где например редьюсер и прочие нерелевантные к извлечению данных вещи.

Сам файл может выглядеть так:

export const all = state => state.counter

export const count = state => all(state).count
export const step = state => all(state).step

export const nextCount = state => count(state) + step(state)
export const prevCount = state => count(state) - step(state)

Кстати, как видите, у функций нет префикса select. Об этом будет подробнее в следующем пункте.

Зачем это нужно?

Когда селекторы расположены в одном месте, мы получаем сразу массу преимуществ.

Во-первых, мы не размазываем логику получения данных из стора по куче анонимных функций, передаваемых в useSelector. Ну или по множеству mapStateToProps (если вдруг кто-то все еще использует connect =)

Соответственно, раз анонимных функций и прочих прелестей больше нет - при внесении изменений не надо ничего искать по всему проекту. К тому же у селекторов теперь есть понятные имена.

Во-вторых, селекторы становятся переиспользуемыми и инкапсулируют в себе определенную логику. Плюсы можно увидеть уже в примере выше: nextCount и prevCount уже не знают о том, как работает count и не зависят от местоположения этой части данных в сторе. А внешний код тем более не должен знать детали получения каких-то данных, особенно если стор имеет специфичную структуру.

В-третьих, раз близкие по фиче селекторы находятся в одном файле - их можно удобно использовать в соседних селекторах. Например, комбинировать или просто создавать вложенные селекторы.

3. Группировать селекторы в один объект (namespace)

Как вы помните, в примере из прошлого пункта у функций-селекторов в названии не было слова select.

Здесь тоже простая логика - зачем нам каждый раз повторяться, если можно просто объединить селекторы в один namespace? Например так:

// counter/model/index.js
export * as counterSelectors from './selectors'

Теперь, при использовании во внешнем коде, мы можем просто написать следующее:

const Counter = () => {
	const count = useSelector(counterSelectors.count)
}

Зачем это нужно?

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

Допустим, если бы у нас еще была фича likes, состояние которой содержит поле count - могло бы появиться 2 селектора с именем selectCount.

При чем selectCount ничего не говорит о фиче, к которой относится селектор, и поэтому в нем требуются дополнительные уточнения, и в итоге мы получаем что-то вроде selectLikesCount. С перспективы внутри фичи это выглядит странно - ведь и так из контекста понятно, что селектор относится к likes. Скорее, такое название функция должна получать на этапе реэкспорта наружу как часть публичного интерфейса модуля.

Все эти проблемы решает объединение в namespace. И это довольно важный пункт.

Из потенциальных минусов - проблемы с code splitting. Но часто ли вы видите ситуацию, что на каком-то этапе можно не загружать все селекторы? Да и вообще, как много места они займут в чанке?

4. Не использовать мемоизацию, когда она не нужна

Вы наверное знаете про библиотеку reselect.

Многие, только ознакомившись с этой библиотекой, принимаются абсолютно каждый селектор создавать через createSelector. Такой подход конечно же неправильный, и мемоизированные селекторы полезны не во всех случаях.

Иногда мемоизированные селекторы только усложнят код, замедлят его, или будут расходовать лишнюю память.

На этом пункте мы закончим с основными правилами и перейдем к видам селекторов - там я как раз объясню, как писать селекторы в различных ситуациях.

Виды селекторов

  • Мемоизированные селекторы, созданные через createSelector из reselect

  • Селекторы без мемоизации, в которых мы ручками принимаем state и возвращаем нужные данные

Мемоизированные селекторы

В редаксе нельзя подписаться на изменение конкретного кусочка данных. Изначально, можно лишь узнать о том, что "где-то что-то изменилось".

Поэтому, при совершенно любом изменении где угодно в сторе, будут вызваны все активные mapStateToProps и useSelector. Соответственно, все селекторы, которые были в них переданы, тоже будут вызваны.

Именно из этого факта вытекает необходимость в мемоизированных селекторах.

Тяжелые вычисления

Вы же не хотите, чтобы тяжелые преобразования выполнялись на каждое изменение в сторе? Например, для интернет-магазина, что насчет пересчитать корзину покупок после того, как пользователь открыл какую-нибудь модалку, состояние которой хранится глобально в редаксе?

Вот-вот =)

Эту проблему решают мемоизированные селекторы - они проверяют входные данные функции, и если они не изменились - сразу же возвращают уже существующее значение, которое было рассчитано ранее.

// только оплаченные элементы
const paidItems = createSelector(
  items,
  items => items.filter(filters.onlyPaid)
)

// только оплаченная сумма
const paidAmount = createSelector(
  paidItems,
  items => items.reduce(reducers.total, 0)
)

// общая сумма покупок
const totalAmount = createSelector(
  items,
  items => items.reduce(reducers.total, 0)
)

Преобразование данных и композиция

const loadingState = state => ({
  isLoading: isLoading(state),
  isLoaded: isLoaded(state),
  isFailed: isFailed(state),
})

Из-за проблемы редакса, которая была описана выше, селектор вызовется даже тогда, когда изменившиеся данные не имеют к нему никакого отношения.

Но, как видите, в примере нет никаких тяжелых вычислений. Тут важно другое - данный селектор каждый раз возвращает новый объект с новой ссылкой.

Проблемы начинаются тогда, когда от данного объекта начинают зависеть компоненты, хуки, и другие селекторы.

Почти везде сравнение происходит по ссылке:

  • useSelector и connect увидят, что результат изменился, и перерендерят компонент. И не важно, что содержимое объекта один в один равно предыдущему.

  • Другие мемоизированные селекторы увидят, что входные данные изменились, и выполнятся заново. И ладно, если эти селекторы возвращают примитивы. Но если они также возвращают объекты - это может привести к еще большей цепочке новых расчетов.

  • Хуки, зависимости которых содержат данный объект, вызовутся заново. В лучшем случае - это дополнительные расчеты, в худшем - вызов важной не идемпотентной бизнес логики.

Поэтому, используем мемоизированный селектор:

const loadingState = createSelector(
  isLoading,
  isLoaded,
  isFailed,
  (isLoading, isLoaded, isFailed) => ({
    isLoading,
    isLoaded,
    isFailed
  })
)

Возможен и другой случай:

const somePrimitive = createSelector(
  isA,
  isB,
  isC,
  (isA, isB, isC) => {
    return isA && isB && isC
  }
)

Этот селектор возвращает примитивное значение и не делает никаких тяжелых расчетов. Так что с точки зрения оптимизации нам не нужно здесь использовать createSelector. Более того, мемоизированный селектор будет выполнять больше вычислений и занимать больше памяти (хоть и ненамного).

А вот то же самое обычным селектором:

const somePrimitive = state => {
  return isA(state) && isB(state) && isC(state)
}

Мне кажется, такой селектор не так приятно выглядит и при увеличении зависимостей быстро приходит в не очень читабельное состояние. А если попытаться исправить это, мы столкнемся с конфликтами между именами селекторов и переменных.

В таких случаях я отдаю предпочтение версии c createSelector, хоть она и уступает по производительности. В контексте всего приложения разница будет несущественная.

Обычные селекторы

const all = state => state.cart

const items = state => all(state).items

const calculation = state => all(state).calculation

const bonuses = state => calculation(state).bonuses

Такие селекторы стоит использовать всегда, когда мы напрямую ссылаемся на данные из стора. Даже если возвращаемое значение является объектом, не стоит беспокоиться - мы лишь возвращаем ссылку на уже существующий объект, который находится в стейте, так что ни к каким проблемам это не приведет.

Мемоизированные селекторы в подобных случаях использовать не стоит — из-за проверки входных данных и сравнения их с предыдущими, такие селекторы будут медленнее (~ в 30 раз в последнем Chrome), а объем занимаемой памяти увеличится, так как предыдущие входные данные нужно где-то хранить. Проблема с памятью не очень заметна, но становится вполне ощутима, когда входными данными является объект с множеством данных.

Re-reselect - селекторы с более сложным кэшем

Многие любят создавать селекторы, принимающие пропсы.
Например, так можно вынести из компонента получение конкретного юзера по ID.

Я не топлю за подобное, но, возможно, есть случаи когда селектор не просто что-то получает по ID, а делает тяжелые расчеты - может тогда это и оправдано.

Но дело в том, что у селекторов, созданных с помощью reselect, объем кэша (сохраненных вычислений) равен 1.

Это означает, что если вы зарендерите 10 компонентов, каждый из которых вызывает один и тот же селектор с разными аргументами - мемоизация не будет работать так, как вам хотелось бы. Например, в случае со списком юзеров, селектор, при каждом его вызове, будет видеть, что ID изменился - расчет будет выполнен заново, а единственный элемент кэша будет перезаписан. Такая мемоизация, если и будет работать, то в очень редких случаях.

Для решения этой проблемы была создана библиотека re-reselect. Она позволяет создавать селекторы с более умным и вместительным кэшем.

В случае с юзерами - при вызове такого селектора с разными ID, кэш для каждого из них будет сохраняться и использоваться при следующих вызовах. Вы можете прочитать как это работает в GitHub библиотеки.

А теперь пример:

// features/user/model/selectors.js

import { createCachedSelector } from 're-reselect'

const all = state => state.users

const users = state => all(state).users

const usersById = createSelector(
  users,
  users => users.reduce((acc, user) => {
    acc[user.id] = user
    return acc
  }, {})
)

const userById = createCachedSelector(
  (_, id) => id,
  usersById,
  (usersById, id) => usersById[id] ?? null
)(
  id => id
)

// features/user/components/profile.js

const UserProfile = ({ id }) => {
  const user = useSelector(state => userSelectors.userById(state, id))
}

Упс, это что, создание нового селектора в компоненте?
Да, выше я писал о том, что не нужно так делать.
Но конкретно в этом случае я не нашел другого выхода.

Впрочем, сравнение аргументов с предыдущими - не то, чтобы очень тяжелая операция.
Но, согласитесь, все же лучше, когда код не выполняется лишний раз.

Вы можете завернуть создание селектора в useCallback, но это также лишние операции, хоть, скорее всего, их и будет меньше, чем у селектора. Тем не менее, я здесь я постарался соблюсти баланс между количеством кода и влиянием на производительность.

Немного о useSelector

Может показаться, что useSelector позволяет не использовать мемоизированные селекторы, но это не так.

Первая причина - функция, переданная внутрь, выполняется при каждом изменении в сторе. Это означает, что различные тяжелые вычисления будут в любом случае выполнены, не важно изменилась ли вообще часть данных стейта, с которой функция работает. В отличие от мемоизированных селекторов, useSelector не может сравнить входящие аргументы функции (в данном случае он всего один - state, который и не выйдет сравнить без глубокого сравнения).

Вторая причина - нельзя отказываться от удобной возможности для композиции селекторов. Как уже обсуждалось здесь, без reselect такое не сделать без боли (либо понадобится писать свой хелпер).

Чтобы предотвратить ререндер, если не изменился результат селектора, useSelector, так же как и connect, сравнивает результаты текущего и прошлого выполнения функции (в случае с connect этой функцией является mapStateToProps). Отличие в том, что по дефолту useSelector производит простое сравнение по ссылке, а connect выполняет shallowCompare (поверхностное сравнение, при котором сравнивается содержимое объектов, но только первая вложенность).

При использовании useSelectorподобное можно сделать с помощью второго параметра, в который можно передать свою функцию для сравнения, в том числе и shallowCompare.

Причина, по которой так сделано изначально - атомарный подход при использовании хуков. Так же, как и в случае с this.state и хуком useState, вместо создания одного большого объекта мы несколько раз вызываем хуки, ответственные за определенную часть данных. Поэтому shallowCompare изначально не требуется и нужен в редких случаях.

// здесь просто сравнить не выйдет
// объект каждый раз новый, так что надо проверять содержимое
const mapStateToProps = state => ({
  one: selectors.one(state),
  two: selectors.two(state),
})

// а здесь для результатов можно использовать простое сравнение
const one = useSelector(selectors.one)
const two = useSelector(selectors.two)

То есть, при работе с useSelector, крайне нежелательно делать так:

/*
 * При совершенно любом изменении state, данный селектор будет вызван
 * А так как он каждый раз возвращает новый объект,
 * - наш компонент будет всегда ререндериться
 * Проблему можно решить с помощью передачи shallowCompare 2 аргументом,
 * - но это лишь костыль
 * Правильное решение - разбиение на несколько вызовов useSelector:
 * Первый получает one, второй - two
 *
 * (функция-селектор написана прямо в компоненте
 * только ради наглядности, не стоит так делать)
 */
useSelector(state => ({
  one: state.one,
  two: state.two
}))

Заключение

Соблюдайте основные правила

  • Выносите селекторы в отдельный слой (например на уровне модуля) и далее используйте их в остальных участках приложения.

  • Объединяйте селекторы в объект (namespace), чтобы избежать повторений кода и конфликтов имен. Лучше всего для этого подходит ре-экспорт.

  • Не рассчитывайте на useSelector в плане оптимизаций. Он лишь предотвращает ререндер, если сравнение результата через === вернуло true.

Используйте обычные селекторы без мемоизации когда:

  • Нужно просто достать значение из стора.

  • (не обязательно) Нужно сделать простую операцию между какими-то значениями, при этом результатом этой операции является примитив.

Используйте мемоизированные селекторы когда:

  • В селекторе есть тяжелые вычисления (фильтрация, сортировка, сложное преобразование данных, и так далее).

  • Результатом вызова селектора является объект. Ну и конечно же, это касается массивов и различных структур вроде Set и Map, так как они тоже являются объектами.

Используйте re-reselect когда:

  • Стандартный кэш из reselect не справляется

Изучайте интересные вещи

  • Effector - очень крутой стейт-менеджер.
    Multi-store, декларативность, инструменты для flow-control.
    Никаких спорных вещей вроде Proxy или декораторов.

  • Feature Sliced - отличная архитектурная методология для фронтенда.