Пагинация

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

Наиболее распространённый способ — передача параметров страницы в query endpoint. Обычно это page и limit, либо offset и limit, в зависимости от API.

import { createApi, fetchBaseQuery } fr om '@reduxjs/toolkit/query/react'

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: ({ page = 1, lim it = 10 }) => ({
        url: 'posts',
        params: { page, limit }
      })
    })
  })
})

export const { useGetPostsQuery } = api

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

  • page=1 — один кэш-ключ
  • page=2 — другой кэш-ключ

Особенность поведения кэша

Ключевой момент заключается в том, что RTK Query рассматривает аргументы как часть идентификатора запроса. Поэтому:

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

создают два независимых куска состояния в кэше.

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

Offset-based пагинация

Некоторые API используют offset вместо page. Это даёт более точный контроль над смещением.

getPosts: builder.query({
  query: ({ offset = 0, limit = 10 }) => ({
    url: 'posts',
    params: { offset, limit }
  })
})

Связь page и offset

= ( - 1)

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

Infinite scroll и накопление данных

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

Подход через selectFromResult

Один из способов — использовать selectFromResult для объединения кэша:

const useInfinitePosts = ({ limit }) => {
  const result = useGetPostsQuery({ page: 1, limit })

  const pages = result.data?.pages ?? []

  return {
    ...result,
    data: pages.flat()
  }
}

Однако такой подход ограничен, так как не сохраняет все страницы автоматически.

Правильная модель: ручное хранение страниц через merge

Более устойчивый способ — использовать serializeQueryArgs и merge внутри createApi.

getPosts: builder.query({
  query: ({ page = 1, limit = 10 }) => ({
    url: 'posts',
    params: { page, limit }
  }),

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

  merge: (currentCache, newItems, { arg }) => {
    if (arg.page === 1) {
      return newItems
    }

    currentCache.push(...newItems)
  },

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

Поведение этой модели

  • все страницы используют один кэш-ключ (по endpointName)
  • данные агрегируются вручную через merge
  • управление страницами происходит через аргументы

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

serializeQueryArgs и управление кэшем

Функция serializeQueryArgs определяет, как RTK Query группирует запросы в кэш.

По умолчанию:

serializeQueryArgs: ({ endpointName, queryArgs }) =>
  `${endpointName}(${JSON.stringify(queryArgs)})`

Для пагинации это не всегда подходит, потому что каждая страница становится отдельным ключом.

Унификация кэша

serializeQueryArgs: ({ endpointName }) => endpointName

Такой подход объединяет все страницы в одну сущность.

merge как механизм накопления

Функция merge вызывается при каждом новом запросе, если кэш уже существует.

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

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

  • currentCache — это уже накопленные данные
  • newItems — результат нового запроса
  • возвращать новый массив не обязательно, допустима мутация

forceRefetch и контроль обновлений

Пагинация требует точного контроля повторных запросов:

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

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

Проблема дубликатов данных

При агрегации страниц часто возникает проблема повторов. Она решается на уровне merge:

merge: (currentCache, newItems) => {
  const existingIds = new Set(currentCache.map(item => item.id))

  newItems.forEach(item => {
    if (!existingIds.has(item.id)) {
      currentCache.push(item)
    }
  })
}

Это особенно важно при нестабильной серверной пагинации.

Cursor-based пагинация

Альтернативой offset/page является cursor-based модель, где сервер возвращает курсор последнего элемента.

query: ({ cursor, limit }) => ({
  url: 'posts',
  params: { cursor, limit }
})

Пример структуры ответа

{
  "items": [...],
  "nextCursor": "abc123"
}

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

merge: (currentCache, newResponse) => {
  currentCache.items.push(...newResponse.items)
  currentCache.nextCursor = newResponse.nextCursor
}

Cursor-подход обеспечивает:

  • стабильную пагинацию без смещений
  • отсутствие проблем при вставке/удалении данных
  • более предсказуемое поведение на больших наборах данных

Инвалидация данных при пагинации

Пагинированные данные требуют аккуратной инвалидизации:

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

При мутациях можно инвалидировать:

invalidatesTags: [{ type: 'Post', id: 'PARTIAL-LIST' }]

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

Синхронизация UI и пагинации

RTK Query предоставляет состояние загрузки:

  • isFetching — идёт любой запрос
  • isLoading — первый запрос
  • isSuccess — данные получены

Для пагинации важно различать первичную загрузку и подгрузку страниц:

const { data, isFetching } = useGetPostsQuery({ page })

При бесконечной прокрутке обычно:

  • первая загрузка блокирует интерфейс
  • последующие только показывают индикатор подгрузки

Оптимизация повторных запросов

RTK Query автоматически избегает повторных запросов при одинаковых аргументах, однако при пагинации это поведение может быть изменено через refetchOnMountOrArgChange.

refetchOnMountOrArgChange: true

Использование этого параметра оправдано, когда данные часто меняются.

Архитектурный выбор модели пагинации

Существует три основных подхода:

1. Страница как ключ кэша

  • простая реализация
  • нет объединения данных
  • подходит для таблиц

2. Единый кэш + merge

  • гибкая модель
  • используется для infinite scroll
  • требует контроля дубликатов

3. Cursor-based

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

Каждый из подходов требует разной настройки RTK Query, особенно в части serializeQueryArgs, merge и forceRefetch.

Поведение при смене фильтров

При добавлении фильтров важно учитывать сброс кэша:

query: ({ page, limit, filter }) => ({
  url: 'posts',
  params: { page, limit, filter }
})

Если используется единый кэш, фильтр должен входить в ключ сериализации:

serializeQueryArgs: ({ endpointName, queryArgs }) => {
  return `${endpointName}-${queryArgs.filter}`
}

Иначе данные разных фильтров смешаются.

Итоговая модель работы пагинации в RTK Query

Пагинация в RTK Query не является встроенной абстракцией уровня UI, а реализуется через комбинацию:

  • параметров запроса
  • управления кэш-ключами
  • кастомного merge
  • контроля refetch логики

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