How to Create an Infinite List with Tanstack (React Query & Virtual)

Ammar MirzaAmmar Mirza
11 min read

What's up, Techies?

As you are already familiar with the title, today we are going to discuss creating an infinite list with the help of Tanstack Query and Tanstack Virtual.

We will be using a virtualized list approach to render a huge list of items so that it renders efficiently. Let's talk about virtualized list first.

Why Virtualized List?

You might be thinking, why use virtualized lists? Can't we just map over an array of items? Fair argument. Initially, this was my stance too, but when I looked at the performance issues of a regular infinite list on large datasets, I immediately bought into the idea of using virtualized lists instead.

Let me explain you further with a diagram

As you can see above, virtualized lists only render the items that are visible in the viewport plus a few extra items around it.

With small datasets (20-30 items), you won't see much difference. But with large datasets (1,000,000+ items), virtualized lists are more efficient. Regular infinite scroll with an array.map() method keeps all items in memory, thus rendering a huge DOM of elements. Virtualized lists avoid this by removing items that are not visible in the viewport. So no matter how big your list is, your DOM will only consist of enough elements to scroll smoothly.

Convincing enough? Let's implement it.

Prerequisites

Before delving deep into this, I suggest that you should be familiar with Next.js server and client components and a lil bit of tailwind.

Boilerplate

We will start by setting up a new Next.js project.

npx create-next-app tanstack-virtualized-list

Then we will install required packages for creating virtualized list.

// Tanstack Query and Tanstack Virtual
npm i @tanstack/react-query @tanstack/react-virtual

We are done with the installation part here, now we will jump onto the implementation.

Implementation

I am using Hashnode's GraphQL API for this demonstration, but you can use any other API of your choice if you prefer.

Setting up the data fetching function

export const fetchData = <TData, TVariables>(
  query: string,
  variables?: TVariables,
): (() => Promise<TData>) => {
  return async () => {
    const res = await fetch("https://gql.hashnode.com", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      credentials: "include",
      body: JSON.stringify({
        query,
        variables,
      }),
    });
    if (!res.ok) {
      throw new Error(`HTTP status code: ${res.status}`);
    }

    const json = await res.json();

    if (json.errors) {
      const { message } = json.errors[0] || {};
      throw new Error(message || "Error…");
    }

    return json.data;
  };
};

This is the function we will use to make our API calls. We will create it and store it in a separate file to use later.

Pretty self explanatory, right?

Wrapping components with QueryClientProvider

We are using React Query, and it involves a little bit of setup before we can query any data.

Whenever we create a project using Tanstack Query, it is essential to wrap all components with the QueryClientProvider.

QueryClientProvider creates a React Context that stores the QueryClient instance.
This context allows components to access the same QueryClient instance, ensuring consistency and avoiding the creation of multiple instances.
This is crucial for managing shared data and avoiding unnecessary re-fetching across different components.

We can also pass optional parameters to QueryClient, like stale time. But for now, we are not going to discuss it.

// ReactQueryProvider.tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export const ReactQueryProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};
// layout.tsx
import { ReactQueryProvider } from "@/components/ReactQueryProvider";

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  return (
    <html lang="en">
      <body>
        <ReactQueryProvider>
            {children}
        </ReactQueryProvider>
      </body>
    </html>
  );
}

Like this we have now created a separate component for our QueryClientProvider.

Prefetching Query (Optional)

To be honest, prefetching is optional, but getting the initial data on the server side instead of the client side makes the page much faster. It reduces the initial load time and speeds up the page loading significantly.

We will have a list of items where data will be fetched in small batches. We can prefetch the first batch using Tanstack's prefetchInfiniteQuery functions.

prefetchInfiniteQuery takes three arguments: queryKey, queryFunc, and initialPageParam.
queryKey can be used to access prefetched data from the cache anytime, and queryFunc requires a fetcher that returns a promise. We will learn more about these shortly.

// layout.tsx

import { QueryClient } from "@tanstack/react-query";
import { HOST_NAME, MAX_PAGE_SIZE, POSTS_QUERY } from "@/utils/constants";
import { fetchData } from "@/utils/fetchData";

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  // create a new client
  const queryClient = new QueryClient();
  // prefetch first batch of items.
  await queryClient.prefetchInfiniteQuery({
    queryKey: ["posts"],
    queryFn: () =>
      fetchData(POSTS_QUERY, {
        host: HOST_NAME, // your publication domain
        pageSize: MAX_PAGE_SIZE, // number of items to load in first batch
        page: 1, // batch number
      })(),
    initialPageParam: 1,
  });

  return (
    <html lang="en">
      // Rest of the code as it is...
    </html>
  );
}
// utils/constants.ts

export const POSTS_QUERY = `query Posts($host: String, $pageSize: Int!, $page: Int!) {
    publication(host: $host) {
      postsViaPage(pageSize: $pageSize, page: $page) {
        nodes {
          id
          title
          brief
        }
        pageInfo {
          hasNextPage
          nextPage
        }
      }
    }
  }`;

export const HOST_NAME = "engineering.hashnode.com";

export const MAX_PAGE_SIZE = 10;

The prefetchInfiniteQuery function requires 3 main arguments, with optional ones like getNextPageParam and pages if you want to prefetch multiple pages at once.

  1. queryKey :- Tanstack Query works on key-value pairs, so make sure to use the same query key when prefetching and using cached data. If the query key is different, the data will be fetched again instead of being used from the cache.

  2. queryFn :- Pass the fetcher function we created above with the necessary arguments. Our fetchData function takes two arguments: Query and Variables.

  3. initialPageParam :- In this, we need to specify the initial page number of our data. It should be an integer.

Like this, we have successfully prefetched our initial batch of items.

Wrapping up with Hydration Boundary (Optional)

  • Whenever we prefetch the data at server-side, it is necessary to wrap the children with Hydration Boundary.

  • We can prefetch queries on the server and then dehydrate the query client state.

  • On the client-side, the HydrationBoundary component rehydrates this state, providing immediate access to the prefetched data without unnecessary re-fetching.

// layout.tsx
import { ReactQueryProvider } from "@/components/ReactQueryProvider";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
    // Rest code as it is...
  return (
    <html lang="en">
      <body>
        <ReactQueryProvider>
          <HydrationBoundary state={dehydrate(queryClient)}>
            {children}
          </HydrationBoundary>
        </ReactQueryProvider>
      </body>
    </html>
  );
}

Using prefetched data with useInfiniteQuery hook

// page.tsx

"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { HOST_NAME, MAX_PAGE_SIZE, POSTS_QUERY } from "@/utils/constants";
import { fetchData } from "@/utils/fetchData";

export default function Home() {
  const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
    useInfiniteQuery({
      queryKey: ["posts"],
      queryFn: ({ pageParam }) =>
        fetchData(POSTS_QUERY, {
          host: HOST_NAME,
          pageSize: MAX_PAGE_SIZE,
          page: pageParam as number,
        })(),
      initialPageParam: 1,
      getNextPageParam: (lastPage: any) => {
        return lastPage.publication?.postsViaPage.pageInfo.nextPage ?? null;
      },
    });

  return (
    <></>
  );
}

Since useInfiniteQuery is a hook and hooks can only be used on client side, we need to make our Home page a client component.

We will use useInfiniteQuery similarly to prefetchInfiniteQuery. The only difference is that we now have an additional required argument called getNextPageParam, It is a callback function which returns either a value or null, we are using this value in our fetchData function's variable. It helps us fetch data based on the page number.

We are using pageInfo in Hashnode's API to check whether if we have a next page or not.

The useInfiniteQuery returns several values, and we are extracting a few of them for our use.

  1. data :- This refers to the value we get from the API. Each page is stored in the data.pages array.

  2. hasNextPage :- This is a boolean value that indicates whether there is a next page or not.

  3. fetchNextPage :- This function is responsible for fetching the next page whenever we need it.

  4. isFetchingNextPage :- This is a boolean value that specifies whether the fetchNextPage function is currently being called.

We will store all the post nodes in a variable named posts like this.

const posts = data?.pages.flatMap((page: any) => page.publication?.postsViaPage.nodes) || [];

We do have our posts ready, now it's time to introduce TanStack Virtual.

Setting up TanStack Virtual

TanStack Virtual makes it so easier to create an infinite list and the DX is even better if you're using it with React Query.
It provides a hook called useVirtualizer, which returns a standard Virtualizer instance configured to work with an HTML element as the scrollElement.
This function requires 3 arguments : count, getscrollElement and estimateSize.

// page.tsx

"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useCallback, useRef } from "react";

export default function Home() {

  // Rest of the code as it is...
  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: hasNextPage ? posts.length + 1 : posts.length,
    getScrollElement: useCallback(() => parentRef.current, []),
    estimateSize: useCallback(() => 150, []),
    overscan: 2,
    gap: 15,
  });

  return (
    <div
      ref={parentRef}
      className="w-full flex flex-col overflow-auto h-[500px]"
    >
      <div
        className="relative w-full"
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const isLoaderRow = virtualRow.index > posts.length - 1;
          const post = posts[virtualRow.index];
          if (!post) return null;
          return (
            <div
              key={virtualRow.index}
              className="w-full absolute border"
              style={{
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <div className="flex flex-col gap-3">
                <p className="font-bold">{post.title}</p>
                <p>{post.brief}</p>
              </div>
              {isLoaderRow && hasNextPage && "Loading..."}
            </div>
          );
        })}
      </div>
    </div>
}
  1. count: number - The total number of items to virtualize.

  2. getScrollElement: () => TScrollElement - A callback that returns the scrollable element for the virtualizer. It can return null if the element is not available yet.

  3. estimateSize: (index: number) => number - This function receives the index of each item and should return the actual size of the items. Since we are using dynamic height, we will return the closest possible value for the items.

  4. overscan?: number :- The number of items to render above and below the visible area. Increasing this number will make the virtualizer take longer to render, but it might reduce the chances of seeing blank items at the top and bottom when scrolling.

  5. gap?: number :- This option lets you set the spacing between items in the virtualized list. It's useful for keeping a consistent visual separation between items without manually adjusting each item's margin or padding. The value is specified in pixels.

We need to assign our parentRef to the scrollable element. Inside that, we insert another div with a relative position. Within that div, we map over all the items with a div as an outer cover. The innermost div which is getting mapped over and over again will have an absolute position.

Note :- Giving the middle div a relative position and the inner div an absolute position is necessary for virtualization to work properly.

Handling estimatedSize for responsive view

We could have given a fixed height to the items, but since the width changes with screen size, we are using dynamic heights for the items.

  • The element with the parentRef will remain unchanged.

  • We will set the height of the relatively positioned div inside using the getTotalSize function.

  • Then, we will assign ref and data-index to the div that is being mapped over. For the ref, we will pass measureElement from the Virtualizer instance.

// page.tsx

export default function Home() {
    // Rest code as it is...
  return (
    // Final returned JSX
    <div
      ref={parentRef}
      className="w-full flex flex-col overflow-auto h-[500px]"
    >
      <div
        className="relative w-full"
        style={{
          height: rowVirtualizer.getTotalSize(),
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const isLoaderRow = virtualRow.index > posts.length - 1;
          const post = posts[virtualRow.index];
          if (!post) return null;
          return (
            <div
              key={virtualRow.index}
              data-index={virtualRow.index}
              className="w-full absolute border"
              ref={rowVirtualizer.measureElement}
              style={{
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <div className="flex flex-col gap-3">
                <p className="font-bold">{post.title}</p>
                <p>{post.brief}</p>
              </div>
              {isLoaderRow && hasNextPage && "Loading..."}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Handling fetchNextPage function

Next we will handle the fetchNextPage function which we are getting from the useInfiniteQuery to fetch next page once we scroll to the last item.

useEffect(() => {
    const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();

    if (!lastItem) {
      return;
    }

    if (
      lastItem.index >= posts.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    hasNextPage,
    fetchNextPage,
    posts.length,
    isFetchingNextPage,
    rowVirtualizer.getVirtualItems(),
  ]);

We get the last item using the getVirtualItems function provided by the library. Then, we call the fetchNextPage function whenever we reach the last item and there are more pages to load.

And like this we have successfully created our Virtualized list.

Demo

GitHub Repo to copy and use code directly 👇https://github.com/iammarmirza/tanstack-virtualized-list.git

Just make sure to drop a ⭐️

Final thoughts

In this article, we explore how to create an efficient infinite list using Tanstack Query and Tanstack Virtual. We discuss the benefits of virtualized lists over traditional infinite scroll methods, especially for large datasets. The tutorial covers setting up a Next.js project, installing necessary packages, and implementing data fetching with React Query. We also delve into prefetching data, wrapping components with QueryClientProvider, and using the useInfiniteQuery hook. Finally, we demonstrate how to set up Tanstack Virtual for rendering the list and handling dynamic item heights for a responsive view.

I have personally tried many ways to create an infinite list but Tanstack is my favourite way of doing it. The developer experience it offers is unmatched.

Do follow me on twitter/X if you come this far : x.com/iammarmirza

58
Subscribe to my newsletter

Read articles from Ammar Mirza directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ammar Mirza
Ammar Mirza

Just a normal guy who loves tech, food, coffee and video-games.