Оптимальное использование кэша

RTK Query строит работу вокруг автоматического кэширования данных. После выполнения запроса результат сохраняется во внутреннем store Redux и может повторно использоваться без дополнительного обращения к серверу. Это снижает количество HTTP-запросов, уменьшает нагрузку на backend и ускоряет отображение интерфейса.

Кэш в RTK Query обладает несколькими особенностями:

  • хранится централизованно в Redux Store;
  • автоматически переиспользуется между компонентами;
  • умеет инвалидироваться;
  • поддерживает подписки;
  • удаляется при отсутствии активных подписчиков;
  • синхронизируется между query и mutation.

Грамотное использование кэша напрямую влияет на производительность приложения.


Базовая схема кэширования

После первого запроса данные помещаются в store:

const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    endpoints: (builder) => ({
        getPosts: builder.query({
            query: () => '/posts'
        })
    })
})

При использовании:

const { data } = useGetPostsQuery()

RTK Query:

  1. выполняет запрос;
  2. сохраняет результат;
  3. подписывает компонент на изменения;
  4. возвращает данные из кэша при повторном использовании.

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

const { data } = useGetPostsQuery()

новый HTTP-запрос не выполняется. Используется уже существующий cache entry.


Cache Key

RTK Query формирует уникальный ключ кэша на основе:

  • имени endpoint;
  • аргументов query.

Пример:

useGetPostQuery(1)
useGetPostQuery(2)

Создаются разные cache entries:

getPost(1)
getPost(2)

Это позволяет независимо хранить данные.


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

Аргументы query сериализуются автоматически.

Пример:

useGetPostsQuery({
    page: 1,
    limit: 10
})

RTK Query создаёт сериализованный ключ:

getPosts({"page":1,"limit":10})

Если структура объекта одинакова, кэш будет переиспользован.


Избежание нестабильных аргументов

Проблема:

useGetPostsQuery({
    page: currentPage,
    timestamp: Date.now()
})

timestamp постоянно меняется, поэтому RTK Query считает запрос новым.

Результат:

  • постоянные повторные запросы;
  • отсутствие reuse cache;
  • деградация производительности.

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

useGetPostsQuery({
    page: currentPage
})

keepUnusedDataFor

По умолчанию RTK Query удаляет данные из кэша через 60 секунд после исчезновения последнего подписчика.

Настройка:

getPosts: builder.query({
    query: () => '/posts',
    keepUnusedDataFor: 300
})

Здесь кэш хранится 5 минут.

Когда увеличивать время хранения

Подходит для:

  • справочников;
  • редко изменяемых данных;
  • конфигураций;
  • пользовательских настроек;
  • категорий товаров.

Когда уменьшать

Подходит для:

  • realtime-информации;
  • быстро меняющихся списков;
  • финансовых данных;
  • статусов процессов.

refetchOnMountOrArgChange

Позволяет повторно загружать данные даже при наличии кэша.

useGetPostsQuery(undefined, {
    refetchOnMountOrArgChange: true
})

Поведение:

  • компонент монтируется;
  • RTK Query проверяет cache;
  • выполняется новый запрос.

Числовое значение refetchOnMountOrArgChange

Можно задать число:

useGetPostsQuery(undefined, {
    refetchOnMountOrArgChange: 30
})

Это означает:

  • если кэшу менее 30 секунд — используются cache data;
  • если более 30 секунд — выполняется refetch.

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


refetchOnFocus

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

const api = createApi({
    baseQuery,
    refetchOnFocus: true,
    endpoints: () => ({})
})

Поведение:

  1. пользователь переключается на другую вкладку;
  2. возвращается обратно;
  3. RTK Query выполняет refetch.

Это удобно для:

  • dashboards;
  • административных панелей;
  • чатов;
  • систем мониторинга.

refetchOnReconnect

Автоматический refetch после восстановления сети:

const api = createApi({
    baseQuery,
    refetchOnReconnect: true,
    endpoints: () => ({})
})

Сценарий:

  1. интернет пропадает;
  2. запросы становятся неактуальными;
  3. соединение восстанавливается;
  4. RTK Query обновляет данные.

pollingInterval

Polling позволяет периодически обновлять cache.

useGetNotificationsQuery(undefined, {
    pollingInterval: 5000
})

Запрос выполняется каждые 5 секунд.

Важно понимать:

  • cache entry остаётся тем же;
  • RTK Query обновляет существующий кэш;
  • компоненты автоматически получают новые данные.

selectFromResult

Одно из ключевых средств оптимизации ререндеров.

Проблема:

const { data } = useGetPostsQuery()

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

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

const { post } = useGetPostsQuery(undefined, {
    selectFromResult: ({ data }) => ({
        post: data?.find(post => post.id === 5)
    })
})

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


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

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

const { data } = useGetPostsQuery()

Если в массиве изменился один элемент — обновится весь компонент.

Лучший вариант:

const { item } = useGetPostsQuery(undefined, {
    selectFromResult: ({ data }) => ({
        item: data?.[0]
    })
})

RTK Query сравнивает результаты selector и избегает лишних обновлений.


Нормализация данных

Крупные массивы хуже подходят для точечных обновлений.

Плохой подход:

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

Лучший вариант — normalized state:

{
    ids: [1, 2],
    entities: {
        1: { id: 1, name: 'A' },
        2: { id: 2, name: 'B' }
    }
}

RTK Query хорошо работает вместе с createEntityAdapter.


createEntityAdapter и cache

Пример:

const postsAdapter = createEntityAdapter()

const initialState = postsAdapter.getInitialState()

getPosts: builder.query({
    query: () => '/posts',
    transformResponse: (response) => {
        return postsAdapter.setAll(initialState, response)
    }
})

Преимущества:

  • быстрый доступ по id;
  • минимальные изменения;
  • меньше ререндеров;
  • удобные selectors.

transformResponse

Не стоит хранить в кэше сырые данные сервера, если они неудобны.

Пример:

getPosts: builder.query({
    query: () => '/posts',
    transformResponse: (response) => {
        return response.items
    }
})

Или:

transformResponse: (response) => {
    return response.map(post => ({
        ...post,
        fullName: `${post.author} (${post.id})`
    }))
}

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


Избежание вычислений в render

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

const { data } = useGetPostsQuery()

const filtered = data?.filter(post => post.active)

Фильтрация выполняется на каждом рендере.

Лучше:

transformResponse: (response) => {
    return response.filter(post => post.active)
}

updateQueryData

RTK Query позволяет обновлять cache вручную без повторного запроса.

Пример optimistic update:

dispatch(
    api.util.updateQueryData(
        'getPosts',
        undefined,
        (draft) => {
            draft.push(newPost)
        }
    )
)

Это особенно эффективно:

  • при добавлении элементов;
  • локальных изменениях;
  • мгновенном UI response.

patchQueryData

Позволяет применять частичные изменения.

dispatch(
    api.util.patchQueryData(
        'getPost',
        5,
        (draft) => {
            draft.title = 'Updated'
        }
    )
)

Преимущество — отсутствие полного refetch.


invalidateTags

Инвалидация — основной механизм синхронизации кэша.

Пример query:

getPosts: builder.query({
    query: () => '/posts',
    providesTags: ['Posts']
})

Mutation:

addPost: builder.mutation({
    query: (body) => ({
        url: '/posts',
        method: 'POST',
        body
    }),
    invalidatesTags: ['Posts']
})

После mutation RTK Query:

  1. помечает cache устаревшим;
  2. автоматически выполняет refetch.

Точная инвалидизация

Глобальная инвалидизация может быть дорогой.

Плохо:

invalidatesTags: ['Posts']

Лучше:

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

Mutation:

invalidatesTags: (result, error, id) => [
    { type: 'Posts', id }
]

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


Предзагрузка данных

RTK Query поддерживает prefetch.

dispatch(
    api.util.prefetch(
        'getPosts',
        undefined,
        {
            force: true
        }
    )
)

Полезно:

  • перед переходом на страницу;
  • при hover;
  • для ускорения навигации.

lazy queries

Иногда не требуется автоматическая загрузка.

const [trigger, result] = useLazyGetPostsQuery()

Запрос выполняется только вручную:

trigger()

Это снижает количество ненужных cache entries.


skip

Можно полностью отключить query.

const { data } = useGetPostQuery(id, {
    skip: !id
})

Без skip RTK Query может создавать лишние запросы.


skipToken

Более безопасный вариант:

import { skipToken } from '@reduxjs/toolkit/query'

const result = useGetPostQuery(
    id ?? skipToken
)

Особенно полезно с TypeScript.


Разделение больших endpoint

Плохо:

getDashboardData

Возвращает:

  • пользователя;
  • уведомления;
  • статистику;
  • графики;
  • настройки.

Минусы:

  • огромный cache entry;
  • частые refetch;
  • массовые ререндеры.

Лучше:

getProfile
getNotifications
getStats
getSettings

Небольшие cache entries легче обновлять и переиспользовать.


Streaming Updates

RTK Query поддерживает websocket-интеграции через onCacheEntryAdded.

Пример:

getMessages: builder.query({
    query: () => '/messages',

    async onCacheEntryAdded(
        arg,
        {
            updateCachedData,
            cacheDataLoaded,
            cacheEntryRemoved
        }
    ) {
        const socket = new WebSocket('ws://localhost')

        await cacheDataLoaded

        socket.onmess age = (event) => {
            const message = JSON.parse(event.data)

            updateCachedData((draft) => {
                draft.push(message)
            })
        }

        await cacheEntryRemoved

        socket.close()
    }
})

Преимущества:

  • realtime cache updates;
  • отсутствие постоянных refetch;
  • минимальный сетевой трафик.

Дедупликация запросов

RTK Query автоматически объединяет одинаковые запросы.

Если одновременно вызываются:

useGetPostsQuery()
useGetPostsQuery()
useGetPostsQuery()

выполняется только один HTTP-запрос.

Остальные компоненты подписываются на существующий cache entry.


Cache lifetime и memory leaks

Слишком большое время хранения кэша может привести к:

  • росту памяти;
  • устаревшим данным;
  • переполнению store.

Особенно опасно:

keepUnusedDataFor: 36000

для крупных datasets.


Разделение критичных и некритичных данных

Не все данные требуют одинаковой свежести.

Пример:

Критичные данные

pollingInterval: 3000
refetchOnFocus: true

Подходит для:

  • балансов;
  • заказов;
  • статусов.

Некритичные данные

keepUnusedDataFor: 3600

Подходит для:

  • справочников;
  • категорий;
  • настроек.

Оптимизация pagination cache

При пагинации важно избегать смешивания страниц.

Правильно:

useGetPostsQuery({
    page: 1
})

Каждая страница получает отдельный cache key.


Infinite scroll и merge cache

RTK Query позволяет объединять страницы.

Пример:

serializeQueryArgs: ({ endpointName }) => {
    return endpointName
},

merge: (currentCache, newItems) => {
    currentCache.push(...newItems)
},

forceRefetch({ currentArg, previousArg }) {
    return currentArg !== previousArg
}

Так формируется единый cache entry.


forceRefetch

Позволяет вручную определить условия обновления.

forceRefetch({
    currentArg,
    previousArg
}) {
    return currentArg.page !== previousArg.page
}

Это полезно для сложной пагинации и infinite scroll.


SSR и кэш

При Server-Side Rendering RTK Query умеет:

  • гидратировать cache;
  • переиспользовать server data;
  • избегать повторных запросов.

Это особенно важно для:

  • Next.js;
  • SEO;
  • ускорения first paint.

resetApiState

Полная очистка кэша:

dispatch(api.util.resetApiState())

Обычно используется:

  • при logout;
  • смене пользователя;
  • очистке sensitive data.

Практические рекомендации

Не хранить всё в одном endpoint

Небольшие cache entries эффективнее.

Не инвалидировать лишние данные

Точная invalidation значительно снижает refetch.

Использовать selectFromResult

Это один из главных инструментов оптимизации.

Минимизировать polling

Постоянные запросы быстро перегружают backend.

Использовать optimistic updates

Это уменьшает необходимость refetch.

Хранить нормализованные структуры

Особенно для крупных коллекций.

Избегать нестабильных query arguments

Иначе кэширование теряет смысл.

Настраивать lifetime под тип данных

Разным данным нужна разная стратегия хранения.