TanStack Query: Майстерність Керування Станом Сервера

Пагінація та Infinite Scroll

Списки даних — це хліб і масло веб-розробки. Але завантажувати 10,000 записів одразу — погана ідея. Нам потрібна пагінація або "нескінченна прокрутка".

Пагінація та Infinite Scroll

Списки даних — це хліб і масло веб-розробки. Але завантажувати 10,000 записів одразу — погана ідея. Нам потрібна пагінація або "нескінченна прокрутка".

TanStack Query перетворює це складне завдання на задоволення.

Плавна Пагінація (Paginated Queries)

Припустимо, ми маємо звичайний запит:

const { data, isPending } = useQuery({
  queryKey: ['projects', page],
  queryFn: () => fetchProjects(page),
});

Проблема: Коли користувач натискає "Next Page", page змінюється з 1 на 2.

  1. Ключ змінюється ['projects', 1] -> ['projects', 2].
  2. Так як даних для сторінки 2 ще немає в кеші, запит переходить в стан isPending: true.
  3. Користувач бачить "Loading..." спіннер і список зникає. Це "смиканий" UX.

Рішення: Ми хочемо залишити дані сторінки 1 на екрані, поки завантажується сторінка 2.

У v5 для цього використовується placeholderData.

ProjectsList.tsx
import { useQuery, keepPreviousData } from '@tanstack/react-query';

function ProjectsList() {
  const [page, setPage] = useState(1);

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    // ✨ МАГІЯ ТУТ
    placeholderData: keepPreviousData, 
  });

  return (
    <div>
      {isPending ? (
        <div>Loading...</div>
      ) : (
        <div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
          {data.items.map(project => (
            <div key={project.id}>{project.name}</div>
          ))}
        </div>
      )}
      
      <button onClick={() => setPage(old => old - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(old => old + 1)}>
        Next
      </button>
    </div>
  );
}

Як це працює?

  • Коли ми переходимо на сторінку 2, Query не вмикає isPending.
  • Замість цього, він показує дані зі сторінки 1 (Previous Data), але позначає їх як isPlaceholderData: true.
  • У фоні вантажиться сторінка 2.
  • Як тільки вона завантажиться, UI плавно оновлюється.

Prefetching (Попереднє Завантаження)

Щоб зробити UX ще кращим, ми можемо завантажити сторінку 2 заздалегідь, поки користувач ще дивиться на сторінку 1.

// В useEffect, коли змінюється сторінка
useEffect(() => {
  if (!isPlaceholderData && data?.hasMore) {
    queryClient.prefetchQuery({
      queryKey: ['projects', page + 1],
      queryFn: () => fetchProjects(page + 1),
    });
  }
}, [data, isPlaceholderData, page, queryClient]);

Тепер при натисканні "Next", дані з'являться миттєво, бо вони вже в кеші.


Infinite Scroll (useInfiniteQuery)

Пагінація "Завантажити ще" (як у Twitter/Instagram) вимагає іншого підходу. Нам потрібно не замінювати сторінку 1 на 2, а додавати сторінку 2 до сторінки 1.

Для цього є спеціальний хук useInfiniteQuery.

Структура API

Ваш бекенд повинен повертати щось на зразок курсора для наступної сторінки:

{
  "items": [...],
  "nextCursor": 20
}

Реалізація

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

function InfiniteFeed() {
  const {
    data, // Структура змінилась!
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed(pageParam),
    // Початковий курсор
    initialPageParam: 0,
    // Логіка отримання наступного курсора з відповіді бекенда
    getNextPageParam: (lastPage, allPages) => {
      // lastPage — це те, що повернув останній запит
      return lastPage.nextCursor ?? undefined; 
      // Якщо повертаємо undefined, hasNextPage стане false
    },
  });

  if (status === 'pending') return <div>Loading...</div>;
  if (status === 'error') return <div>Error: {error.message}</div>;

  return (
    <div>
      {/* data.pages — це масив масивів (масив сторінок) */}
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.items.map((project) => (
            <div key={project.id} className="card">
              {project.name}
            </div>
          ))}
        </React.Fragment>
      ))}

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

      <div>{isFetching && !isFetchingNextPage ? 'Background Updating...' : null}</div>
    </div>
  );
}

Важливі відмінності data

У useQuery data — це просто відповідь. У useInfiniteQuery data — це об'єкт:

{
  pages: [responsePage1, responsePage2, ...],
  pageParams: [param1, param2, ...]
}

Тому нам завжди треба робити подвійний map (спочатку по сторінках, потім по елементах сторінки).

Bi-directional Infinite Scroll

v5 також підтримує двонаправлений скрол (вгору і вниз, як у месенджерах), використовуючи getPreviousPageParam.

Далі: Просунуті Патерни та Залежні Запити

Copyright © 2026