Коротко: Руководство по типизации 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>;
Разбор ключевых параметров типов
TQueryFnData— тип данных, возвращаемый функциейqueryFnза один HTTP-запрос (тип одиночной страницы).TError— тип ошибки, перехватываемой при сбое запроса (по умолчаниюDefaultError/Error).TData— итоговый тип поляdata, возвращаемого хуком. По умолчанию этоInfiniteData<TQueryFnData, TPageParam>. Если используется трансформация через селекторselect, этот дженерик отражает тип результата селектора.TQueryKey— тип ключа запроса (кортежreadonly unknown[]).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 строится на согласованности трех компонентов:
- Контракта данных страницы (
TQueryFnData). - Типа курсора (
TPageParam) с явнымinitialPageParam. - Корректных условий остановки в
getNextPageParam.
Отказ от ручного приведения типов в пользу автоматического вывода через infiniteQueryOptions и типизированный QueryFunctionContext позволяет выявлять ошибки несовместимости API еще на этапе компиляции, защищая приложение от сбоев пагинации во время выполнения.




.svg.webp)


