Dev Highlights

Доки по разработке

This project is maintained by teniryte

8. Пагинация и Infinite Query

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

  1. Страничная (offset-based)useQuery на каждую страницу, управление page и кэшом.
  2. Бесконечная (cursor-based)useInfiniteQuery, курсоры и стек страниц/курсорных ответов.

Важно: в v5 gcTime по умолчанию 5 минут, поэтому при переключении страниц реже 5 минут данные не будут удаляться; при необходимости увеличьте gcTime или используйте meta.keepForever.

Offset пагинация с keepPreviousData

const pageSize = 20;

const { data, isFetching, isPlaceholderData } = useQuery({
  queryKey: ['projects', { page, pageSize }],
  queryFn: () => fetchProjects({ page, pageSize }),
  placeholderData: keepPreviousData,
});

Cursor пагинация через useInfiniteQuery

import { 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));

Ограничение размера стека

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

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 кнопки «Следующая страница».

Автоподгрузка по IntersectionObserver

const sentinelRef = useIntersectionObserver({
  onIntersect: () => hasNextPage && !isFetchingNextPage && fetchNextPage(),
  rootMargin: '400px',
});

return (
  <>
    {data?.pages.flatMap(...)}
    <div ref={sentinelRef}>{isFetchingNextPage && 'Загрузка...'}</div>
  </>
);

Сброс при фильтрации

const filters = useMemo(() => ({ status, sort }), [status, sort]);

const query = useInfiniteQuery({
  ...ticketsQuery(filters),
  enabled: !!projectId,
});

Меняйте queryKey, когда фильтры обновляются, чтобы кеш не смешивался.

Восстановление скролла

SSR + бесконечные запросы

События и оптимизация

Мутации и синхронизация списков

Управление состоянием загрузки

Типичные подводные камни

Полезные утилиты и лайфхаки