Dev Highlights

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

This project is maintained by teniryte

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

В TanStack Query есть два основных сценария:

  1. offset/page pagination через обычный useQuery
  2. cursor/infinite pagination через useInfiniteQuery

Offset pagination

const projectsQuery = useQuery({
  queryKey: ['projects', { page, pageSize, status }],
  queryFn: () => fetchProjects({ page, pageSize, status }),
  placeholderData: (previousData) => previousData,
})

Это современный v5-паттерн для плавного перехода между страницами. Исторический helper keepPreviousData больше не является центральной рекомендацией; основной способ сегодня это placeholderData: (prev) => prev.

Что даёт placeholderData

if (query.isPlaceholderData) {
  // например, можно временно блокировать кнопку Next
}

Включайте параметры страницы в queryKey

Правильно:

['projects', { page, pageSize, status }]

Неправильно:

Иначе страницы будут смешиваться в кеше.

Предзагрузка соседней страницы

useEffect(() => {
  if (!query.data?.hasMore) return

  void queryClient.prefetchQuery({
    queryKey: ['projects', { page: page + 1, pageSize, status }],
    queryFn: () => fetchProjects({ page: page + 1, pageSize, status }),
    staleTime: 30_000,
  })
}, [page, pageSize, status, query.data?.hasMore, queryClient])

Это уменьшает perceived latency при клике “Next”.

Infinite Query

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

const messagesQuery = useInfiniteQuery({
  queryKey: ['messages', roomId],
  initialPageParam: null as string | null,
  queryFn: ({ pageParam, signal }) =>
    fetchMessages({ roomId, cursor: pageParam, signal }),
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})

Ключевые поля:

Рендер infinite list

return (
  <>
    {messagesQuery.data.pages.flatMap((page) =>
      page.items.map((message) => (
        <MessageRow key={message.id} message={message} />
      )),
    )}

    <button
      onClick={() => messagesQuery.fetchNextPage()}
      disabled={!messagesQuery.hasNextPage || messagesQuery.isFetchingNextPage}
    >
      {messagesQuery.isFetchingNextPage ? 'Loading...' : 'Load more'}
    </button>
  </>
)

maxPages

Если лента очень длинная, не держите весь стек страниц бесконечно:

useInfiniteQuery({
  queryKey: ['feed'],
  initialPageParam: null,
  queryFn: ({ pageParam }) => fetchFeed(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  maxPages: 10,
})

Это полезно для чатов, activity feed и мобильных интерфейсов.

usePrefetchInfiniteQuery

Для Suspense и предварительной загрузки во время render-подготовки:

usePrefetchInfiniteQuery({
  queryKey: ['feed'],
  initialPageParam: null,
  queryFn: ({ pageParam }) => fetchFeed(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

Этот hook ничего не возвращает. Его задача только прогреть кеш до useSuspenseInfiniteQuery.

SSR и hydration для infinite query

Правило:

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

Обновление infinite cache после мутации

queryClient.setQueryData(['messages', roomId], (old: InfiniteData<MessagesPage>) =>
  old
    ? {
        ...old,
        pages: old.pages.map((page, index) =>
          index === 0
            ? { ...page, items: [newMessage, ...page.items] }
            : page,
        ),
      }
    : old,
)

Для insert/delete в середине списка часто проще и безопаснее инвалидировать, чем пытаться идеально патчить все страницы.

Infinite scroll и IntersectionObserver

Обычный паттерн:

При больших списках почти всегда стоит комбинировать это с виртуализацией вроде @tanstack/react-virtual.

Типичные ошибки