Бесконечный скролл с React Query: паттерны и антипаттерны

#react#react-query#infinite-scroll

TL;DR

React Query + Intersection Observer — мощная комбинация для infinite scroll, но требует правильной настройки getNextPageParam. Разбираем как избежать типичных ошибок с пагинацией и оптимизировать запросы.

Введение: когда классическая пагинация не катит

В современных SPA бесконечный скролл стал де-факто стандартом для лент контента. Но его реализация часто превращается в ад из:

React Query с его useInfiniteQuery обещает избавить нас от этих проблем, но как показывает пример из Reddit — без понимания механики можно настрелять себе в ногу.

Разбираем проблемный код

Исходный пример содержит несколько критических ошибок:

// ❌ Проблемный момент 1: page из Redux
export const useFetch = () => {
  const {page} = useAppSelector((state)=> state.page)
  
  const {data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey:['data',page], // ❌ Зависимость от внешнего стейта
    queryFn: () => getImages(),
    getNextPageParam:(last_page) => last_page.page // ❌ Не учитывает next_page
  })
  
  return {data, isLoading, fetchNextPage, hasNextPage}
}

Что не так:

  1. Избыточная зависимость от Redux: useInfiniteQuery уже имеет внутренний механизм пагинации
  2. Неправильный getNextPageParam: просто возвращая текущую страницу, мы ломаем механизм подгрузки
  3. Отсутствие обработки next_page: API явно предоставляет URL следующей страницы, но мы его игнорируем

Правильная реализация

Перепишем хук с учетом best practices:

export const useFetchImages = () => {
  return useInfiniteQuery({
    queryKey: ['images'],
    queryFn: ({ pageParam = 1 }) => getImages(pageParam),
    getNextPageParam: (lastPage) => {
      // Используем next_page из API или вычисляем следующую страницу
      return lastPage.next_page 
        ? extractPageFromUrl(lastPage.next_page)
        : lastPage.page + 1
    },
    staleTime: 5 * 60 * 1000, // 5 минут
    getPreviousPageParam: (firstPage) => firstPage.page - 1,
  })
}

// Хелпер для извлечения page из URL
const extractPageFromUrl = (url: string) => {
  const match = url.match(/page=(\d+)/)
  return match ? parseInt(match[1]) : undefined
}

Ключевые изменения:

Оптимизируем Intersection Observer

Оригинальная реализация useBottomIndicator имеет проблемы с memory leaks:

const useScrollTrigger = () => {
  const ref = useRef<HTMLDivElement>(null)
  const [isIntersecting, setIntersecting] = useState(false)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIntersecting(entry.isIntersecting)
      },
      { threshold: 0.1 }
    )

    const current = ref.current
    if (current) observer.observe(current)

    return () => {
      if (current) observer.unobserve(current) // ✅ Важно!
    }
  }, [])

  return { ref, isIntersecting }
}

Собираем всё вместе

Финальный компонент будет выглядеть так:

const ImageFeed = () => {
  const { ref, isIntersecting } = useScrollTrigger()
  const { data, fetchNextPage, hasNextPage, isFetching } = useFetchImages()

  useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetching) {
      fetchNextPage()
    }
  }, [isIntersecting, hasNextPage, isFetching])

  return (
    <div>
      {data?.pages.map((page) => (
        <ImageGrid key={page.page} photos={page.photos} />
      ))}
      <div ref={ref} className="h-2" />
      {isFetching && <Loader />}
    </div>
  )
}

Производительность и оптимизации

  1. Virtualization: Для больших списков подключаем react-window
  2. Запросы: throttle на scroll events
  3. Кеширование: настраиваем через React Query Devtools
  4. Превью: можно сразу подгружать 2-3 страницы
// Пример расширенной конфигурации
useInfiniteQuery({
  // ...,
  initialPageParam: 1,
  maxPages: 5, // Лимит сохраняемых страниц
  refetchOnWindowFocus: false,
})

Заключение

Правильная реализация infinite scroll требует:

  1. Полного доверия механизмам React Query
  2. Грамотной работы с Intersection Observer
  3. Учета особенностей вашего API

Главный урок — не нужно изобретать велосипеды для пагинации. useInfiniteQuery уже содержит все необходимые паттерны, нужно лишь правильно их применить.

Для глубокого погружения рекомендую:


Источник: https://www.reddit.com/r/reactjs/comments/1sw9cmh/infinite_scroll_with_react_query/