Infinite Scroll Component with React Query

October 31, 2022

3 min read

Infinite Scroll Component with React Query
Watch on YouTube

Let's make an infinite scroll component with React Query. Here we have a simple table fetching new items on scroll.


To query an API with paginated results, we are using the useInfiniteQuery hook that receives a key, query function, and configuration. To fake an API, we have the queryItems function that receives the startAt index and returns a list of items together with an index of the next item.

const {
} = useInfiniteQuery(
  async ({ pageParam }) => {
    if (pageParam === null) return

    const result = await queryItems({ startAt: pageParam })

    return result
    refetchOnMount: true,
    keepPreviousData: false,
    getNextPageParam: (lastPage) => lastPage?.nextItem || null,

The page param for the query function comes from the getNextPageParam that converts the last response to the param we need to query the next page. It is null when we are on the final page or the first request.

The data coming from the hook will have a format of pages, and to convert it to a plain list, we leverage the usePaginatedResultItems. It takes data and a function to extract items from the page result. Here we create an empty list, go through every page, and convert them to items.

import { useMemo } from "react"
import { InfiniteData } from "react-query"

export function usePaginatedResultItems<T, V>(
  data: InfiniteData<T | undefined> | undefined,
  getPageItems: (page: T) => V[]
): V[] {
  return useMemo(() => {
    const items: V[] = []

    data?.pages?.forEach((page) => {
      if (page) {

    return items
  }, [data?.pages, getPageItems])

To show the loader and request new items on scroll, we have the PaginatedView component. Here we display children together with the footer. To know if a user scrolled to the end of the list, we are leveraging the useIntersection hook that builds on top of Intersection Observer API. Below we have an effect that will request to load more items in case of intersection.

import { ReactNode, useEffect, useRef } from "react"
import { useIntersection } from "react-use"
import styled from "styled-components"
import { Spinner } from "./Spinner"
import { HStack, VStack } from "./Stack"
import { Text } from "./Text"

interface Props {
  children: ReactNode
  isLoading?: boolean
  onRequestToLoadMore: () => void

const LoaderContainer = styled(HStack)`
  color: ${({ theme }) => theme.colors.textSupporting2.toCssValue()};

const Footer = styled(VStack)`
  grid-column: 1/-1;

export const PaginatedView = ({
}: Props) => {
  const ref = useRef<HTMLDivElement>(null)

  const intersection = useIntersection(ref, {
    root: null,
    rootMargin: "200px",
    threshold: 0,

  useEffect(() => {
    if (intersection?.isIntersecting && !isLoading) {
  }, [intersection?.isIntersecting, onRequestToLoadMore, isLoading])

  return (
      <Footer fullWidth alignItems="center">
        <div ref={ref} />
        {isLoading && (
            <Spinner size={16} />