Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Infinite Queries в TanStack Query: правильная типизация курсоров и страниц в TypeScript

Тимофей Ищенко

Коротко: Руководство по типизации useInfiniteQuery в TanStack Query v5: настройка дженериков, курсорной пагинации, селекторов и фабрики infiniteQueryOptions без any.

Курсорная пагинация (cursor-based pagination) и бесконечные ленты — стандарт для современных веб-приложений. В экосистеме React библиотека TanStack Query (ранее React Query) предоставляет для таких задач специализированный хук useInfiniteQuery.

В отличие от стандартного useQuery, где кешируется одиночный ответ сервера, useInfiniteQuery накапливает данные в двумерную структуру вида { pages: TPage[], pageParams: TPageParam[] }. Когда в проект добавляется строгий TypeScript (strict: true), работа с курсорами нередко превращается в череду небезопасных приведений типов через as, ошибок вывода в getNextPageParam и расхождений между DTO бэкенда и состоянием клиента.

Ниже подробно рассмотрена архитектура дженериков TanStack Query v5, построение типобезопасного API-контракта для курсоров и методы полной изоляции логики запросов с сохранением автоматического вывода типов.


Анатомия дженериков в useInfiniteQuery (v5)

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

function useInfiniteQuery<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = InfiniteData<TQueryFnData>,
  TQueryKey extends QueryKey = QueryKey,
  TPageParam = unknown,
>(
  options: UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
): UseInfiniteQueryResult<TData, TError>;

Разбор ключевых параметров типов

  1. TQueryFnData — тип данных, возвращаемый функцией queryFn за один HTTP-запрос (тип одиночной страницы).
  2. TError — тип ошибки, перехватываемой при сбое запроса (по умолчанию DefaultError / Error).
  3. TData — итоговый тип поля data, возвращаемого хуком. По умолчанию это InfiniteData<TQueryFnData, TPageParam>. Если используется трансформация через селектор select, этот дженерик отражает тип результата селектора.
  4. TQueryKey — тип ключа запроса (кортеж readonly unknown[]).
  5. TPageParam — тип значения курсора (или смещения), передаваемого в queryFn и возвращаемого функциями getNextPageParam / getPreviousPageParam.

Структура InfiniteData<TPage, TPageParam>

Кеш TanStack Query сохраняет историю пагинации в объекте следующего формата:

interface InfiniteData<TData, TPageParam = unknown> {
  pages: Array<TData>;
  pageParams: Array<TPageParam>;
}

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


Типизация API-контракта курсорной пагинации

Курсор — это указатель на позицию записи в базе данных. Сервер может возвращать в качестве курсора строковый ID, Unix-timestamp, base64-токен или составной объект.

Создадим интерфейсы для типовой курсорной пагинации:

// Сущность предметной области
export interface Article {
  id: string;
  title: string;
  slug: string;
  publishedAt: string;
}

// Ответ от API для одной страницы
export interface ArticlesPageDTO {
  items: Article[];
  nextCursor: string | null;
  prevCursor: string | null;
  totalCount: number;
}

Здесь nextCursor имеет тип string | null. Если следующей страницы нет, API возвращает null (или поле отсутствует — undefined). Типизация клиентского кода должна явно учитывать отсутствие значения без принудительного каста к string.


Практическая реализация: типизируем queryFn, initialPageParam и getNextPageParam

В TanStack Query v5 параметр initialPageParam является обязательным. Это устраняет неоднозначность первого запуска, которая в v4 часто приводила к типу pageParam: undefined | T.

Создание типобезопасной функции запроса

import { QueryFunctionContext } from '@tanstack/react-query';

// Тип ключа запроса с фильтрами
type ArticlesQueryKey = readonly ['articles', 'infinite', { category?: string }];

// Тип курсора: string | null
type ArticleCursor = string | null;

export const fetchArticles = async ({
  pageParam,
  queryKey,
  signal,
}: QueryFunctionContext<ArticlesQueryKey, ArticleCursor>): Promise<ArticlesPageDTO> => {
  const [, , filters] = queryKey;
  
  const searchParams = new URLSearchParams();
  if (filters.category) {
    searchParams.set('category', filters.category);
  }
  if (pageParam) {
    searchParams.set('cursor', pageParam);
  }
  searchParams.set('limit', '20');

  const response = await fetch(`/api/articles?${searchParams.toString()}`, { signal });
  
  if (!response.ok) {
    throw new Error(`Failed to fetch articles: ${response.statusText}`);
  }

  return response.json();
};

Конфигурация useInfiniteQuery

При передаче параметров TypeScript автоматически выводит дженерики, если функции написаны строго:

import { useInfiniteQuery } from '@tanstack/react-query';

export const useArticlesInfinite = (category?: string) => {
  return useInfiniteQuery({
    queryKey: ['articles', 'infinite', { category }] as const,
    queryFn: fetchArticles,
    initialPageParam: null as ArticleCursor,
    getNextPageParam: (lastPage): ArticleCursor => {
      // Если API возвращает null или undefined, передаем null/undefined
      return lastPage.nextCursor ?? null;
    },
    getPreviousPageParam: (firstPage): ArticleCursor => {
      return firstPage.prevCursor ?? null;
    },
  });
};

Обратите внимание: TanStack Query считает, что следующая страница отсутствует (hasNextPage === false), если getNextPageParam возвращает null или undefined.


Типизация селекторов (select) для плоских списков

Часто компоненту отображения не нужен вложенный массив pages, а требуется плоский список сущностей Article[]. Для этого используется опция select.

При применении select дженерик TData меняется на возвращаемый тип селектора:

interface ArticlesFlatData {
  articles: Article[];
  totalCount: number;
}

export const useFlatArticles = (category?: string) => {
  return useInfiniteQuery({
    queryKey: ['articles', 'flat', { category }] as const,
    queryFn: fetchArticles,
    initialPageParam: null as ArticleCursor,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
    select: (data): ArticlesFlatData => ({
      articles: data.pages.flatMap((page) => page.items),
      totalCount: data.pages[data.pages.length - 1]?.totalCount ?? 0,
    }),
  });
};

Здесь data внутри компонента получит тип ArticlesFlatData, а вспомогательные флаги isFetchingNextPage, hasNextPage и функция fetchNextPage останутся доступны.


Использование infiniteQueryOptions для переиспользуемых запросов

Для выноса конфигурации запросов и их повторного использования в prefetching, Server Side Rendering (SSR) в Next.js или в клиентских роутерах рекомендуется использовать фабрику infiniteQueryOptions.

import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';

export const articlesInfiniteOptions = (category?: string) => {
  return infiniteQueryOptions({
    queryKey: ['articles', 'list', { category }] as const,
    queryFn: async ({ pageParam, signal }) => {
      const url = new URL('/api/articles', window.location.origin);
      if (category) url.searchParams.set('category', category);
      if (pageParam) url.searchParams.set('cursor', pageParam);

      const res = await fetch(url.toString(), { signal });
      if (!res.ok) throw new Error('Network error');
      return (await res.json()) as ArticlesPageDTO;
    },
    initialPageParam: null as ArticleCursor,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? null,
  });
};

// Использование в компоненте:
export function ArticleFeed({ category }: { category?: string }) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery(articlesInfiniteOptions(category));

  if (status === 'pending') return <div>Загрузка...</div>;
  if (status === 'error') return <div>Ошибка при загрузке данных</div>;

  return (
    <div>
      {data.pages.map((page, i) => (
        <section key={i}>
          {page.items.map((article) => (
            <article key={article.id}>
              <h3>{article.title}</h3>
            </article>
          ))}
        </section>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Загрузка...'
          : hasNextPage
          ? 'Загрузить еще'
          : 'Все данные загружены'}
      </button>
    </div>
  );
}

Распространенные ошибки типизации и способы их решения

1. Несовпадение типов initialPageParam и возвращаемого значения getNextPageParam

Если указать initialPageParam: 0 (число), а getNextPageParam вернет string (курсор), TypeScript выдаст ошибку несовместимости типов в TPageParam.

Решение: Если начальный курсор отсутствует, а последующие представляют собой строки, типизируйте курсор как string | null и передавайте null в качестве initialPageParam.

2. Потеря типобезопасности при ручном обновлении кеша (setQueryData)

При добавлении нового элемента или мутации страницы через queryClient.setQueryData необходимо явно передавать тип структуры InfiniteData:

import { useQueryClient, InfiniteData } from '@tanstack/react-query';

const queryClient = useQueryClient();
const queryKey = ['articles', 'list', { category: 'tech' }] as const;

// Типобезопасное добавление новой статьи в начало первой страницы
queryClient.setQueryData<InfiniteData<ArticlesPageDTO, ArticleCursor>>(
  queryKey,
  (oldData) => {
    if (!oldData || oldData.pages.length === 0) return oldData;

    const newArticle: Article = {
      id: 'new-id',
      title: 'Новая статья',
      slug: 'new-article',
      publishedAt: new Date().toISOString(),
    };

    const firstPage = oldData.pages[0];
    const updatedFirstPage: ArticlesPageDTO = {
      ...firstPage,
      items: [newArticle, ...firstPage.items],
      totalCount: firstPage.totalCount + 1,
    };

    return {
      ...oldData,
      pages: [updatedFirstPage, ...oldData.pages.slice(1)],
    };
  }
);

3. Ограничение количества страниц через maxPages без getPreviousPageParam

В TanStack Query v5 добавлена опция maxPages, позволяющая ограничить объем страниц в памяти (например, хранить не более 3 страниц при бесконечном скролле). Если задан maxPages, обязательно требуется реализация как getNextPageParam, так и getPreviousPageParam, иначе при скролле назад хук не сможет восстановить предыдущие страницы.


FAQ (Часто задаваемые вопросы)

Почему initialPageParam стал обязательным в TanStack Query v5?

В предыдущих версиях параметр initialPageParam был опциональным и неявно инициализировался как undefined. Это приводило к проблемам: функции queryFn приходилось всегда обрабатывать тип pageParam: T | undefined, даже когда использовалась пагинация по целочисленному номеру страницы page: number (начиная с 1). Обязательный initialPageParam сделал вывод типов TPageParam полностью детерминированным.

Как правильно типизировать курсор, если сервер возвращает null в конце списка?

Используйте объединенный тип (Union Type) вида type Cursor = string | null. В initialPageParam передавайте null. В функции getNextPageParam возвращайте lastPage.nextCursor ?? null (или undefined). Библиотека воспринимает оба значения (null и undefined) как сигнал об окончании списка.

Можно ли трансформировать структуру страниц с помощью select так, чтобы вернуть объект с дополнительными вычислениями?

Да. Функция select принимает аргумент типа InfiniteData<TQueryFnData, TPageParam> и может возвращать любую структуру. Возвращаемый селектором тип автоматически становится типом свойства data в результате выполнения хука.

Чем отличается TQueryFnData от TData в useInfiniteQuery?

TQueryFnData описывает структуру данных, возвращаемых единичным вызовом queryFn (одна страница). TData — это тип итогового поля data, которое отдает хук компоненту. Если select не передан, TData равен InfiniteData<TQueryFnData, TPageParam>. Если select задан, TData равен типу возвращаемого значения селектора.

Как избежать дублирования конфигурации между хуком и SSR-загрузчиком?

Используйте фабрику infiniteQueryOptions(...). Она фиксирует типизацию ключа, функции запроса и параметров пагинации. Полученный объект можно без потери типов передавать как в queryClient.prefetchInfiniteQuery(options), так и в useInfiniteQuery(options).


Заключение

Строгая типизация Infinite Queries в TanStack Query v5 строится на согласованности трех компонентов:

  1. Контракта данных страницы (TQueryFnData).
  2. Типа курсора (TPageParam) с явным initialPageParam.
  3. Корректных условий остановки в getNextPageParam.

Отказ от ручного приведения типов в пользу автоматического вывода через infiniteQueryOptions и типизированный QueryFunctionContext позволяет выявлять ошибки несовместимости API еще на этапе компиляции, защищая приложение от сбоев пагинации во время выполнения.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также