Выборочная подписка на данные

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

Каждый вызов хука запроса, например useGetUsersQuery(), создаёт подписку на конкретный cache entry. RTK Query хранит данные по ключу, сформированному из:

  • имени endpoint
  • аргументов запроса

Внутри store это выглядит как запись вида:

state.api.queries[endpointName(argument)]

Несколько компонентов могут подписаться на один и тот же cache entry. В этом случае RTK Query увеличивает счётчик активных подписок, не дублируя запрос.

Ключевая особенность: по умолчанию компонент получает весь результат запроса и ререндерится при любом изменении данных этого cache entry, даже если фактически используется только его часть.

Проблема избыточных ререндеров

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

const { data } = useGetUsersQuery();

Если изменился один элемент массива или пришли новые поля, ссылка на data обновляется, и компонент перерендеривается полностью.

При масштабировании приложения это приводит к:

  • лишним вычислениям в компонентах
  • повторной отрисовке больших списков
  • снижению отзывчивости интерфейса

selectFromResult как механизм выборочной подписки

Основной инструмент RTK Query для выборочной подписки — selectFromResult. Он позволяет подписаться не на весь результат запроса, а на его проекцию.

Базовый принцип

Вместо получения полного объекта результата выполняется селекция:

const { user } = useGetUsersQuery(undefined, {
  selectFromResult: ({ data }) => ({
    user: data?.find(u => u.id === 5)
  })
});

В этом случае компонент зависит не от всего data, а только от вычисленного user.

RTK Query отслеживает зависимости селектора и перерендеривает компонент только при изменении результата функции selectFromResult.

Поведение при изменении кэша

Когда приходит новый ответ или происходит инвалидация:

  1. обновляется cache entry
  2. пересчитываются селекторы
  3. сравнивается результат предыдущего и нового значения
  4. компонент ререндерится только если результат изменился по ссылке

Это поведение делает selectFromResult аналогом оптимизированного useSelector, но привязанного к конкретному endpoint.

Стабильность ссылок и мемоизация

Важный аспект работы выборочной подписки — стабильность возвращаемых значений.

selectFromResult: ({ data }) => ({
  user: data?.find(u => u.id === 5)
})

Если find возвращает новый объект при каждом вычислении (а он возвращает ссылку на элемент массива), RTK Query всё равно будет сравнивать результат селектора. Если ссылка не изменилась — ререндер не произойдёт.

Однако если внутри создаются новые структуры:

selectFromResult: ({ data }) => ({
  user: {
    ...data?.find(u => u.id === 5),
    computed: true
  }
})

то новый объект будет создаваться каждый раз, что приведёт к постоянным ререндерам. В таких случаях требуется дополнительная мемоизация через useMemo или вынос вычислений в селекторы.

Локальные подписки на части списка

Частый кейс — отображение списков, где каждый элемент подписан только на свою сущность.

const selectUserById = (id) => (result) => ({
  user: result.data?.entities?.[id]
});

const UserRow = ({ id }) => {
  const { user } = useGetUsersQuery(undefined, {
    selectFromResult: selectUserById(id)
  });
};

Здесь каждый UserRow подписан только на конкретного пользователя. При изменении другого элемента списка ререндер не произойдёт.

Взаимодействие с нормализацией данных

Выборочная подписка особенно эффективна при использовании нормализованных данных через transformResponse или createEntityAdapter.

Пример структуры:

state.api.queries.getUsers.data = {
  entities: {
    1: {...},
    2: {...}
  },
  ids: [1, 2]
}

В этом случае selectFromResult может точечно подписываться на отдельные сущности без обхода массива.

selectFromResult: ({ data }) => ({
  user: data?.entities[id]
})

Изменение одного entity не затрагивает остальные компоненты, подписанные на другие id.

Сравнение с useSelector

selectFromResult концептуально похож на useSelector, но имеет важное отличие:

  • useSelector работает с глобальным state
  • selectFromResult работает с конкретным cache entry RTK Query

Это означает, что RTK Query уже ограничивает область наблюдения, а selectFromResult дополнительно сужает её до нужного фрагмента данных.

Поведение при refetch и polling

При обновлении данных через:

  • refetch
  • polling (pollingInterval)
  • invalidation

пересчёт selectFromResult происходит автоматически.

Если результат селектора не изменился, компонент остаётся стабильным, даже если сам запрос был выполнен повторно.

Это особенно важно при частых polling-интервалах, где полный ререндер списка был бы слишком дорогим.

Оптимизация вычислений внутри selectFromResult

Поскольку функция выполняется при каждом обновлении cache entry, важно избегать тяжёлых операций:

Плохой вариант:

selectFromResult: ({ data }) => ({
  filtered: data?.filter(x => complexCheck(x))
})

Такой код выполняется при каждом обновлении данных и может стать узким местом.

Практика оптимизации:

  • вынос фильтрации в memoized selector
  • использование reselect
  • предварительная нормализация данных
  • минимизация вычислений внутри selectFromResult

Комбинация нескольких подписок

RTK Query позволяет одному компоненту иметь несколько независимых подписок через разные вызовы хука:

const user = useGetUserQuery(id);
const posts = useGetUserPostsQuery(id);

Но более эффективный вариант — объединение через selectFromResult, чтобы контролировать перерендеры на уровне одного cache entry или минимального набора зависимостей.

Ленивая подписка и частичный доступ к данным

При использовании useLazyQuery механизм подписки остаётся тем же, но активация происходит вручную. selectFromResult при этом позволяет ограничить реактивную часть данных даже после триггера запроса:

const [trigger, result] = useLazyGetUsersQuery();

const { user } = useGetUsersQuery(undefined, {
  selectFromResult: ({ data }) => ({
    user: data?.[0]
  })
});

Таким образом можно отделить момент получения данных от момента подписки на их часть.

Стабилизация компонентов через shallow сравнение

RTK Query использует сравнение ссылок для определения необходимости обновления. При работе с selectFromResult это становится критически важным:

  • примитивы сравниваются напрямую
  • объекты сравниваются по ссылке
  • массивы сравниваются по ссылке

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

Архитектурное применение выборочной подписки

Выборочная подписка применяется не только для оптимизации, но и для архитектурного разделения:

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

Типичный паттерн:

  • список подписан на ids
  • строка списка подписана на конкретный entity
  • детальная карточка подписана на один cache entry

Так достигается предсказуемое поведение обновлений без каскадных ререндеров.

Ограничения подхода

Несмотря на гибкость, selectFromResult имеет ограничения:

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

Поэтому выборочная подписка эффективна только при дисциплине управления данными и понимании механики кеша RTK Query.