Доки по разработке
This project is maintained by teniryte
TanStack Query v5 предлагает два базовых подхода, но внутри каждого скрывается много тонкостей:
useQuery на каждую страницу, управление page и кэшом.useInfiniteQuery, курсоры и стек страниц/курсорных ответов.Важно: в v5 gcTime по умолчанию 5 минут, поэтому при переключении страниц реже 5 минут данные не будут удаляться; при необходимости увеличьте gcTime или используйте meta.keepForever.
keepPreviousDataconst pageSize = 20;
const { data, isFetching, isPlaceholderData } = useQuery({
queryKey: ['projects', { page, pageSize }],
queryFn: () => fetchProjects({ page, pageSize }),
placeholderData: keepPreviousData,
});
placeholderData: keepPreviousData удерживает предыдущую страницу, пока новая загружается.page в URL (useSearchParams) и включайте в queryKey.page на 1 при смене фильтров.total, сохраняйте его в select, чтобы не пересчитывать внутри компонента:
select: data => ({ items: data.items, totalPages: Math.ceil(data.total / pageSize) })
queryKey через массив: ['projects', { page, pageSize, ...filters }]. Это предотвращает кеш-коллизии.enabled: page > 0.useEffect(() => {
if (!data?.hasNextPage) return;
queryClient.prefetchQuery({
queryKey: ['projects', { page: page + 1, pageSize }],
queryFn: () => fetchProjects({ page: page + 1, pageSize }),
staleTime: 30_000,
});
}, [data?.hasNextPage, page, pageSize, queryClient]);
keepPreviousData не блокирует isFetching, поэтому можно отображать спиннер поверх старых данных или индикатор skeleton внизу.suspense: true используйте placeholderData только если понимаете, что Suspense fallback не нужен.useInfiniteQueryimport { useInfiniteQuery } from '@tanstack/react-query';
const messagesQuery = userId => ({
queryKey: ['messages', userId],
queryFn: ({ pageParam }) => fetchMessages({ userId, cursor: pageParam }),
getNextPageParam: lastPage => lastPage.nextCursor ?? undefined,
initialPageParam: null,
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(messagesQuery(currentUserId));
data.pages — массив результатов (каждая страница).getNextPageParam получает последнюю страницу и возвращает курсор для следующей.initialPageParam задаёт курсор для первой страницы.null/undefined из getNextPageParam, чтобы остановить подгрузку.getPreviousPageParam.pageParams хранит курсоры для каждой страницы — можно использовать их для refetchPage.Если пользователь может проскроллить тысячу страниц, ограничивайте длину массива.
const { data } = useInfiniteQuery({
...messagesQuery(userId),
maxPages: 20, // новые страницы вытесняют самые старые
});
refetchPage позволяет перезапрашивать только упавшую страницу:
const retryFailedPage = (pageIdx: number) =>
queryClient.refetchQueries({
queryKey: ['messages', userId],
type: 'active',
exact: true,
meta: { refetchPage: (_, idx) => idx === pageIdx },
});
return (
<>
{data?.pages.flatMap(page =>
page.items.map(message => <Message key={message.id} {...message} />),
)}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Загружаем…' : hasNextPage ? 'Загрузить ещё' : 'Это всё'}
</button>
</>
);
const queryClient = useQueryClient();
const prefetchNext = () =>
queryClient.prefetchInfiniteQuery({
...messagesQuery(userId),
pages: data?.pages,
pageParam: data?.pageParams,
});
Вызывайте на onMouseEnter кнопки «Следующая страница».
IntersectionObserverconst sentinelRef = useIntersectionObserver({
onIntersect: () => hasNextPage && !isFetchingNextPage && fetchNextPage(),
rootMargin: '400px',
});
return (
<>
{data?.pages.flatMap(...)}
<div ref={sentinelRef}>{isFetchingNextPage && 'Загрузка...'}</div>
</>
);
IntersectionObserver до монтирования (проверяйте typeof window).@tanstack/react-virtual и вызывать fetchNextPage при достижении последней виртуальной строки.const filters = useMemo(() => ({ status, sort }), [status, sort]);
const query = useInfiniteQuery({
...ticketsQuery(filters),
enabled: !!projectId,
});
Меняйте queryKey, когда фильтры обновляются, чтобы кеш не смешивался.
scrollTop в useEffect и восстанавливайте при возврате на страницу, если queryClient уже содержит данные.react-router 6.16+ можно использовать useScrollRestoration, но важно вызывать его после того, как data.pages уже восстановились из кеша.queryClient.prefetchInfiniteQuery.initialPageParam и pageParams в dehydrate.initialPageParam в useInfiniteQuery, иначе первая страница запросится повторно.hasPreviousPage/fetchPreviousPage поддерживают прокрутку вверх (Slack-подобные UI).react-virtualized, tanstack-virtual) и обновляйте его при добавлении страниц.totalCount, можно вычислить «загружено из total» и построить индикатор прогресса.queryClient.setQueryData:
const utils = useQueryClient();
const mutation = useMutation({
mutationFn: createMessage,
onSuccess: newMessage => {
utils.setQueryData(['messages', userId], data => {
if (!data) return data;
return {
...data,
pages: data.pages.map((page, idx) =>
idx === 0 ? { ...page, items: [newMessage, ...page.items] } : page,
),
};
});
},
});
utils.invalidateQueries({ queryKey: ['messages', userId] }).structuralSharing: (oldData, newData) => newData по умолчанию уже оптимизирует, но глубокие мутации делайте иммутабельно).isFetching (offset) и isFetchingNextPage (infinite) можно использовать для compact-индикаторов в кнопке или в тулбаре.fetchNextPage возвращает промис — можно await fetchNextPage() в обработчике и ловить ошибки локально.retry: false на бесконечной кнопке удобно комбинировать с ручным toast, чтобы не спамить запросы, пока пользователь не нажмёт «Повторить».cursor=lastItem.updatedAt+id) или фильтруйте Set-ом при мердже страниц.pageSize: при изменении pageSize сбрасывайте queryKey целиком, иначе кешированные страницы будут неконсистентны.tenantId в queryKey, даже если backend фильтрует по токену.sort на уровне API — TanStack Query не гарантирует порядок между страницами, если API возвращает их непоследовательно.queryOptions (messagesQuery, ticketsQuery) и используйте их и в prefetch*, и в use*Query, чтобы избежать рассинхрона.meta: { persist: true } с @tanstack/query-persist-client — бесконечные списки восстанавливаются даже после перезагрузки.fetchNextPage, пока предыдущий промис не завершился (например, с помощью useRef).select с useMemo, чтобы из data.pages сразу строить flatList, totalCount, groupedByDate и т. д., минимизируя перерасчёты.hasNextPage), одна страница, долгие ответы, скачки курсора назад.