Pagination and Infinite Queries with React Query

1
pagination-and-infinite-scroll-with-react-query
fresher-to-uber
Fresher To Uber

Unlock the secrets of efficient data handling with React Query. In this post we learn how to implement pagination for controlled data loading and infinite queries for that endless scroll experience, all while leveraging React Query's smart caching and state management to create a smooth, engaging user interface.

Pagination

I believe we all have worked on paginated API before, typically we will pass page and page_size as API request params. To demonstrate, let set default page_size=10, and we'll keep track the page as React state.

Our Pokémon API response will look like this:

{
  totalElements: 24,
  totalPages: 3,
  data: Array[10]
}

Let's enhance the Pokémon search app that we have done in this .

function App() {
  const [ search, setSearch ] = useState('');
  const [ page, setPage ] = useState(1);

  const searchHandle = (value: string) => {
    setSearch(value)
    setPage(1)
  }

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryDevtools />
      <div className="container flex flex-col gap-4 mx-auto items-center">
        <Input type="text" placeholder="Search pokemon" value={search} onChange={(e) => searchHandle(e.target.value)} /> 
          <PokemonList search={search} page={page} setPage={setPage} />
      </div>
    </QueryClientProvider>
  )
}

In PokemonList component, pass the page prop from parent component to usePokemons so we can use it to fetch a chunk of data.

function usePokemons(search: string, page: number) {
  return useQuery({
    queryKey: ['pokemons', { search, page }],
    queryFn: () => fetchPokemons(search, page),
    enabled: !!search,
  })
}

We also add a paginator component so user can navigate between page. FYI, all the UI components we've used in this example like input, button, card, pagination are come from .

The PokemonList component will looks like this:

function PokemonList({
  search,
  page,
  setPage
}: { 
  search: string,
  page: number,
  setPage: (page: number | ((page: number) => number)) => void,
}) {
  const { data, status, isLoading } = usePokemons(search, page);

  if (isLoading) {
    return (
      // render loading component
    )
  }

  if (status === "error") {
    return (
      // render error component
    )
  }

  if (status === "success") {
    const { totalPages, data: pokemonList } = data;
    return (
      <div className="flex flex-col gap-2 items-center w-full">
        <div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
          {pokemonList.map((pokemon) => (
              <PokemonCard key={pokemon.id} pokemon={pokemon} />
            )
          )}
        </div>
        <Paginator currentPage={page} totalPages={totalPages} setPage={setPage} />
      </div>
    )
  }

  return (
    <div>
      Try to search pokemon!
    </div>
  )
}

function Paginator({ currentPage, totalPages, setPage }: {
  currentPage: number,
  totalPages: number,
  setPage: (page: number | ((page: number) => number)) => void
}) {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem className="hover:cursor-pointer">
          <PaginationPrevious onClick={() => setPage(p => p - 1)}
            disabled={currentPage === 1}
          />
        </PaginationItem>
        {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => {
          return (
            <PaginationItem key={p} className="hover:cursor-pointer">
              <PaginationLink isActive={p === currentPage} onClick={() => setPage(p)}>{p}</PaginationLink>
            </PaginationItem>
          )
        })}
        <PaginationItem className="hover:cursor-pointer">
          <PaginationNext onClick={() => setPage(p => p + 1)} 
            disabled={currentPage === totalPages}
          />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}

We only need to keep track page prop and pass it to useQuery. But we can easily spot that when user switch to another page, the loading indicator show and wipe out the entire list.

What if we keep the old list visible until the new one's ready? This would reduce layout shifts and make the experience feel smoother.
Here's how we can do that with placeholderData:

function usePokemons(search: string, page: number) {
  return useQuery({
    queryKey: ['pokemons', { search, page }],
    queryFn: () => fetchPokemons(search, page),
    enabled: !!search,
    placeholderData: (previousData) => previousData
  })
}

By using placeholderData, we're telling React Query to show the last fetched data (i.e., the previous page) while it's fetching the new one. This keeps the user's context, making page changes feel more seamless.

useQuery also exposes isPlaceholderData property to tell us what data the query is currently providing.

We then pass it to Paginator component so we can disabled the buttons when new data is being fetching and to PokemonCard component so we can show a placeholder while content is loading.

function PokemonList({
  search,
  page,
  setPage
}: { 
  search: string,
  page: number,
  setPage: (page: number | ((page: number) => number)) => void,
}) {
  const { data, status, isLoading, isPlaceholderData } = usePokemons(search, page);

  if (isLoading) {
    return (
      // render loading component
    )
  }

  if (status === "error") {
    return (
      // render error component
    )
  }

  if (status === "success") {
    const { totalPages, data: pokemonList } = data;
    return (
      <div className="flex flex-col gap-2 items-center w-full">
        <div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
          {pokemonList.map((pokemon) => (
              <PokemonCard key={pokemon.id} pokemon={pokemon} isPlaceholderData={isPlaceholderData} />
            )
          )}
        </div>
        <Paginator currentPage={page} totalPages={totalPages} setPage={setPage} isPlaceholderData={isPlaceholderData} />
      </div>
    )
  }

  return (
    <div>
      Try to search pokemon!
    </div>
  )
}

function Paginator({ currentPage, totalPages, isPlaceholderData, setPage }: {
  currentPage: number,
  totalPages: number,
  isPlaceholderData: boolean,
  setPage: (page: number | ((page: number) => number)) => void
}) {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem className="hover:cursor-pointer">
          <PaginationPrevious onClick={() => setPage(p => p - 1)}
            disabled={currentPage === 1 || isPlaceholderData}
          />
        </PaginationItem>
        {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => {
          return (
            <PaginationItem key={p} className="hover:cursor-pointer">
              <PaginationLink isActive={p === currentPage} onClick={() => setPage(p)} disabled={isPlaceholderData}>{p}</PaginationLink>
            </PaginationItem>
          )
        })}
        <PaginationItem className="hover:cursor-pointer">
          <PaginationNext onClick={() => setPage(p => p + 1)} 
            disabled={currentPage === totalPages || isPlaceholderData}
          />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}

And here we get

We've got ourselves a fully paginated feature. But we can do it even better, what if we were always one step ahead, fetching the next page in the background? That way, when users hit "Next" page, the data's already there, and the UI pops up instantly.

Here's how we'd do that:

prefetching

First, we need to extract the query options into a reusable function.

Then in our custom hook usePokemons we prefetch the data for next page.

function fetchPokemonsOptions(search: string, page: number) {
  return queryOptions({
    queryKey: ['pokemons', { search, page }],
    queryFn: () => fetchPokemons(search, page),
    enabled: !!search,
  })
}

function usePokemons(search: string, page: number) {
  const queryClient = useQueryClient()

  // prefetch next page
  React.useEffect(() => {
    queryClient.prefetchQuery(fetchPokemonsOptions(search, page + 1))
  }, [search, page, queryClient])
  
  return useQuery({
    ...fetchPokemonsOptions(search, page),
    placeholderData: (previousData) => previousData
  })
}

With this setup, every time you turn a page, the next one's already waiting in the cache. Now that's an experience that's not just good, it's next-level smooth!

Infinite queries

Infinite scrolling has become an ubiquitous feature in modern web applications. With React Query, implementing infinite lists is not only simple but also optimized for performance.

In traditional pagination, we manage page directly in our state and pass them to queryKey. However, with infinite lists, we want a single cache entry that grows as we load more data. This is where useInfiniteQuery comes into play.

Forget managing page numbers with React state, useInfiniteQuery does it for you. It starts from an initialPageParam (let's say 1) and keeps fetching until we tell it to stop.

function usePokemons(search: string) {
  return useInfiniteQuery({
    queryKey: ['pokemons-infinite-scroll', search],
    queryFn: ({ pageParam }) => fetchPokemons(search, pageParam),
    initialPageParam: 1,
    enabled: !!search,
  })
}

When we need more data, useInfiniteQuery provides a fetchNextPage function. This function uses getNextPageParam to figure out what to fetch next.

function usePokemons(search: string) {
  return useInfiniteQuery({
    queryKey: ['pokemons-infinite-scroll', search],
    queryFn: ({ pageParam }) => fetchPokemons(search, pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages, lastPageParam) => {
      if (lastPage.totalPages === allPages.length) {
        return undefined
      }
      return lastPageParam + 1
    },
    enabled: !!search,
  })
}

The getNextPageParam method have three arguments, lastPage, allPages, and lastPageParam.

  • lastPage is the data from the most recently fetched page.
  • allPages is an array containing all the pages that have been fetched up to this point.
  • lastPageParam is the pageParam that was used to fetch the last page.

As we know our Pokémon API response look like this:

{
  totalElements: 24,
  totalPages: 3,
  data: Array[10]
}

So to tell React Query there is no more data we just simply return undefined, then the scrolling stops:

if (lastPage.totalPages === allPages.length) {
  return undefined
}

Alright, so we've learned how to stash data in the cache using useInfiniteQuery, but how do we retrieve it?

Here's where useInfiniteQuery differs from useQuery in a big way - the data structure.

With useQuery, we directly get the data associated with the queryKey. However, useInfiniteQuery returns a data object looks like this:

{
 "data": {
   "pages": [
     { totalElements: 24, totalPages: 3, data: Array[10] }, /* response from API with page 1 */
     { totalElements: 24, totalPages: 3, data: Array[10] }, /* response from API with page 2 */
     { totalElements: 24, totalPages: 3, data: Array[4] } /* response from API with page 3 */
   ],
   "pageParams": [1, 2, 3]
 }
}

To be able to flatten the data of pages, we can use Array.flat()

const { data, status, isLoading } = usePokemons(search);
const pokemons = data.pages.map(p => p.data).flat();

In our pagination example, managing pages was as simple as incrementing a state variable with a paginator component.

But with useInfiniteQuery, the page management is taken care of for us. We get a fetchNextPage function instead, which works by calling getNextPageParam to determine the next pageParam and then executing the queryFn.
The magic here is straightforward - no new React Query concepts needed. we just need to trigger fetchNextPage when the user hits the bottom of your list.

For this, we'll tap into the useIntersectionObserver from useHooks

import { useIntersectionObserver } from "@uidotdev/usehooks";

const [ref, entry] = useIntersectionObserver({
  threshold: 0,
  root: null,
  rootMargin: "0px",
});

When the element with the ref enters the viewport, entry.isIntersecting flips to true.

function PokemonList({
  search,
}: { 
  search: string,
}) {
  const [ref, entry] = useIntersectionObserver({
    threshold: 0,
    root: null,
    rootMargin: "0px",
  });
  const { data, status, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } = usePokemons(search);

  React.useEffect(() => {
    if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }, [entry?.isIntersecting, hasNextPage, isFetchingNextPage])

  
  if (isLoading) {
    return (
      // loading component
    )
  }

  if (status === "error") {
    return (
      // error component
    )
  }

  if (status === "success") {
    const pokemons = data.pages.map(p => p.data).flat();
    return (
      <>
        <div className="flex flex-col gap-2 items-center">
          <div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
            {pokemons.map((pokemon) => {
              return (
                <PokemonCard key={pokemon.id} pokemon={pokemon} />
              )
            })}
          </div>
          {isFetchingNextPage && (<div className="flex justify-center">
            <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
            </div>
          )}
        </div>
        {hasNextPage && <div className="h-1" ref={ref} />}
      </>
    )
  }

  return (
    <div>
      No Pokemons founded!
    </div>
  )
}

Above we leverage some flags from useInfiniteQuery:

  • isFetchingNextPage: This flag goes true during the fetch of the next page, helping to indicate loading.
  • hasNextPage: This becomes true when there's more content to load, which is figured out by checking if getNextPageParam returns undefined.

Conclusion

We've journeyed through the realms of pagination and infinite queries with React Query, and what a trip it's been! With useQuery for traditional pagination and useInfiniteQuery for that endless scroll feel, we've seen how React Query simplifies the management of large datasets, ensuring your app remains performance and responsive.

And again you can find the example .

1