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 {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
isFetched,
isIdle,
} = useInfiniteQuery(
"items",
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) {
items.push(...getPageItems(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 = ({
children,
isLoading,
onRequestToLoadMore,
}: Props) => {
const ref = useRef<HTMLDivElement>(null)
const intersection = useIntersection(ref, {
root: null,
rootMargin: "200px",
threshold: 0,
})
useEffect(() => {
if (intersection?.isIntersecting && !isLoading) {
onRequestToLoadMore()
}
}, [intersection?.isIntersecting, onRequestToLoadMore, isLoading])
return (
<>
{children}
<Footer fullWidth alignItems="center">
<div ref={ref} />
{isLoading && (
<LoaderContainer
fullWidth
gap={8}
justifyContent="center"
alignItems="center"
>
<Spinner size={16} />
<Text>Loading</Text>
</LoaderContainer>
)}
</Footer>
</>
)
}