How to Create an Infinite List with Tanstack (React Query & Virtual)
Table of contents
- Why Virtualized List?
- Prerequisites
- Boilerplate
- Implementation
- Setting up the data fetching function
- Wrapping components with QueryClientProvider
- Prefetching Query (Optional)
- Wrapping up with Hydration Boundary (Optional)
- Using prefetched data with useInfiniteQuery hook
- Setting up TanStack Virtual
- Handling estimatedSize for responsive view
- Handling fetchNextPage function
- Demo
- Final thoughts
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.
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.queryFn
:- Pass the fetcher function we created above with the necessary arguments. OurfetchData
function takes two arguments: Query and Variables.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.
data
:- This refers to the value we get from the API. Each page is stored in the data.pages array.hasNextPage
:- This is a boolean value that indicates whether there is a next page or not.fetchNextPage
:- This function is responsible for fetching the next page whenever we need it.isFetchingNextPage
:- This is a boolean value that specifies whether thefetchNextPage
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>
}
count: number
- The total number of items to virtualize.getScrollElement: () => TScrollElement
- A callback that returns the scrollable element for the virtualizer. It can return null if the element is not available yet.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.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.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
anddata-index
to the div that is being mapped over. For theref
, we will passmeasureElement
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
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.