Минимизация ререндеров

RTK Query построен на тесной интеграции с Redux и селекторной моделью подписок. Каждый вызов хука автоматически подписывает компонент на часть состояния кеша, и именно здесь чаще всего возникают лишние ререндеры. Понимание механики подписок и стратегии сравнения данных позволяет существенно сократить количество перерисовок UI без отказа от удобства декларативного API.


Механизм подписок и причина ререндеров

Каждый вызов useQuery или useLazyQuery создаёт подписку на конкретный ключ запроса в сторе RTK Query.

При обновлении состояния происходят два уровня проверок:

  1. Проверка ссылки на результат запроса
  2. Проверка селектора внутри кеша

Если ссылка изменилась — компонент гарантированно ререндерится.

Даже если данные по сути эквивалентны, новая ссылка объекта приводит к обновлению UI.


Нормализация данных как базовый способ уменьшения обновлений

RTK Query хранит данные в кеше как «результат запроса», а не как нормализованную сущность по умолчанию. Это означает, что повторная выдача идентичного массива или объекта может приводить к лишним обновлениям.

Основная проблема

builder.query({
  query: () => '/users',
})

Если сервер возвращает новый массив:

[{ id: 1, name: 'A' }]

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


Использование transformResponse для стабилизации данных

Одним из ключевых инструментов является transformResponse.

getUsers: builder.query({
  query: () => '/users',
  transformResponse: (response) => response,
})

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

Часто используется совместно с мемоизацией на уровне сервиса:

let cache;

getUsers: builder.query({
  query: () => '/users',
  transformResponse: (response) => {
    if (cache && cache.length === response.length) return cache;
    cache = response;
    return response;
  },
})

Это простейшая форма ручной стабилизации ссылок.


selectFromResult как инструмент точечной подписки

Ключевой механизм минимизации ререндеров — selectFromResult.

Он позволяет подписываться не на весь результат, а на его часть.

Базовый пример

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

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


Проблема повторного вычисления selectFromResult

Хотя selectFromResult уменьшает объём данных, он может сам стать источником лишних ререндеров, если:

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

Проблемный вариант

selectFromResult: ({ data }) => ({
  user: data?.find(u => u.id === id),
  timestamp: Date.now(),
})

Каждый вызов создаёт новый объект → ререндер.


Мемоизация через createSelector

RTK Query совместим с reselect, что позволяет стабилизировать вычисления.

import { createSelector } from '@reduxjs/toolkit'

const selectUser = createSelector(
  (res) => res.data,
  (_, id) => id,
  (data, id) => data?.find(u => u.id === id)
)

Использование:

const { user } = useGetUsersQuery(undefined, {
  selectFromResult: (result) => ({
    user: selectUser(result, 1),
  }),
})

Это резко снижает количество пересчётов при неизменных входных данных.


Разделение endpoint-ов вместо фильтрации на клиенте

Один из наиболее недооценённых способов оптимизации — перенос фильтрации на уровень endpoint.

Антипаттерн

const { data } = useGetUsersQuery()
const active = data?.filter(u => u.active)

Любое изменение data вызывает перерасчёт фильтра и ререндер.

Правильный подход

getActiveUsers: builder.query({
  query: () => '/users?active=true',
})

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


Использование providesTags и invalidation с осторожностью

RTK Query автоматически инвалидирует данные через providesTags и invalidatesTags.

Чрезмерно широкие теги приводят к каскадным обновлениям.

Проблема

providesTags: ['Users']

Любое обновление пользователя инвалидирует весь список.

Оптимизация

providesTags: (result) =>
  result
    ? [
        ...result.map(({ id }) => ({ type: 'Users', id })),
        { type: 'Users', id: 'LIST' },
      ]
    : [{ type: 'Users', id: 'LIST' }]

Теперь обновляется только конкретная сущность, а не весь список.


Изоляция компонентов с помощью shallow comparison

React-Redux использует === сравнение. Любая новая ссылка вызывает ререндер.

Типичная ошибка:

const { data } = useGetUsersQuery()

const processed = {
  active: data?.filter(Boolean),
  count: data?.length,
}

Каждый рендер создаёт новый объект.

Решение через useMemo

const processed = useMemo(() => ({
  active: data?.filter(Boolean),
  count: data?.length,
}), [data])

Разделение подписок по компонентам

Частая архитектурная ошибка — один компонент подписан на большой набор данных.

Проблема

const { data } = useGetDashboardQuery()

Если dashboard содержит десятки сущностей, любой их апдейт вызывает ререндер всего компонента.

Решение

Разделение endpoint-ов:

useGetUsersQuery()
useGetStatsQuery()
useGetNotificationsQuery()

Каждый компонент подписан только на свою часть кеша.


Использование polling с учётом стоимости ререндеров

Polling увеличивает частоту обновлений, что напрямую влияет на ререндеры.

useGetMessagesQuery(undefined, {
  pollingInterval: 5000,
})

Чем чаще polling, тем важнее:

  • минимизировать размер возвращаемых данных
  • использовать selectFromResult
  • избегать лишних объектов в UI

Skip token и условные подписки

Ненужные подписки создают лишние ререндеры даже без данных.

useGetUserQuery(userId, {
  skip: !userId,
})

или

useGetUserQuery(userId ?? skipToken)

Это предотвращает создание подписки и уменьшает нагрузку на store.


Стабилизация аргументов запроса

RTK Query использует аргументы как часть ключа кеша.

useGetUsersQuery({ page: 1, filter: 'active' })

Если объект создаётся заново:

useGetUsersQuery({ page: currentPage, filter })

и currentPage или filter пересоздаются, возможны лишние запросы и ререндеры.

Решение

const params = useMemo(() => ({
  page: currentPage,
  filter,
}), [currentPage, filter])

Снижение ререндеров через структурирование кеша

RTK Query хранит данные по endpoint + аргументы.

Грубая гранулярность аргументов приводит к фрагментации кеша:

useGetPostsQuery({ page: 1, sort: 'asc' })
useGetPostsQuery({ page: 2, sort: 'asc' })

Каждая комбинация создаёт отдельный slice кеша.

Оптимизация:

  • минимизировать вариативность аргументов
  • использовать нормализацию на уровне backend
  • использовать merge в endpoint
merge: (currentCache, newItems) => {
  currentCache.push(...newItems)
}

Селекторы состояния кеша вместо прямого доступа

Прямое чтение данных из store:

state.api.queries

приводит к лишним зависимостям.

Правильный подход:

api.endpoints.getUsers.select()

Это гарантирует стабильность структуры и минимальные пересчёты.


Итоговая архитектурная модель минимизации ререндеров

Эффективная работа RTK Query строится на сочетании:

  • точечных подписок (selectFromResult)
  • мемоизации (createSelector, useMemo)
  • разделения endpoint-ов
  • контроля invalidation через теги
  • стабилизации аргументов запросов
  • сокращения объёма кешируемых данных

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