React Virtual List: Revolutionizing List-Making
In this post, I will show you how to efficiently render 100,000 rows in React.
At some point in developing an application, you will likely encounter the problem of needing to render a large amount of data on the screen. Rendering all this data at once is not performant.
You can't just throw 100,000 DOM nodes into the browser (no matter which framework you are using) and expect it to be fast, because it won't be.
To solve this, we have various solutions. The oldest and most popular library is called react-virtualized
.
It can not only solve this problem but also offers many additional features that go beyond just rendering a list.
Another library from the same author is react-window
.
It's newer and smaller. The author stripped away all features unrelated to the functionality of a virtual list and improved the API.
Another library, which is slightly less popular, is called react-virtuoso
.
Tanstack Virtual
My favorite library in this list is tanstack/virtual
.
If you don't know about Tanstack, it consists of several libraries that are fully covered with TypeScript at a high level and have an amazing API. The most popular library in Tanstack is react-query
, which you are likely familiar with, as many people use it to efficiently work with APIs in React and synchronize API data with their components.
Tanstack Virtual also integrates well with Tanstack Query, and I will show you how to use them together.
Tanstack Virtual is a library that works with frameworks like React, Vue, Solid, and Svelte. Its goal is to render large lists of data efficiently. Another important point is that this library is a CDK (Component Development Kit). Unlike other solutions, it doesn't include any styling or pre-made components. This is a big plus since we often prefer custom markup and styling over standard library styles, which we would likely override later.
Simple List
Our first step here is to install the library itself.
npm i @tanstack/react-virtual
Additionally, to generate a lot of fake data, I will use the Faker.js library.
npm i @faker-js/faker
I generated my React application using TypeScript, as it works so well with Tanstack/virtual
.
import SimpleList from "./SimpleList";
const App = () => {
return <SimpleList />;
};
export default App;
With a simple list, we want to render a large list of predefined values. No API calls are needed—just data on the frontend.
import { useVirtualizer } from "@tanstack/react-virtual";
import { faker } from "@faker-js/faker";
const SimpleList = () => {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});
const users = new Array(10000).fill(true).map(() => faker.person.fullName());
}
To start using tanstack-virtual
, we need to create a rowVirtualizer
by calling useVirtualizer
. Inside, we pass the total number of items, the parent DOM element, the size of each element, and the number of items to render on the left and right.
Additionally, we generated a list of 10,000 users with random names.
const SimpleList = () => {
...
return (
<div className="container" ref={parentRef}>
<div
className="list"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
className={`row ${
virtualRow.index % 2 ? "list-item-odd" : "list-item-even"
}`}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{users[virtualRow.index]}
</div>
))}
</div>
</div>
);
};
Here we've added markup for our list. It consists of a parent container with parentRef
. We then set the size of the container based on the number of items and the size of each item. Inside, we render a list of virtual items from rowVirtualizer
. Since all data is inside users
, we just need the correct virtualRow.index
to retrieve the data of a specific user.
Our container is only 500px in height, and we have a virtual list inside.
Most importantly, the number of DOM nodes is not huge. We render fewer than 20 elements in the list—only the elements visible in the container, plus 5 elements above and below. When we scroll, these 20 elements are re-rendered, but our markup remains the same. All elements that are off-screen are removed from the tree.
Async List
But this is not typically how we get our data. We rarely have all data on the client, and we rely on APIs to provide chunks of data for us. So, it looks more like an infinite loading list. We need to apply the same rules, as simply appending elements from the API to our list can slow the page over time.
A good solution is to use tanstack-virtual
together with tanstack-query
to implement this.
import { QueryClient, QueryClientProvider } from "react-query";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
The first step is to wrap the entire application with QueryClientProvider
from tanstack-query
. This allows us to use it in any component.
// src/AsyncList.jsx
async function fetchServerPage(
limit: number,
offset: number = 0
): Promise<{ rows: string[]; nextOffset: number }> {
const rows = new Array(limit)
.fill(0)
.map(() => `${faker.person.fullName()} ${offset * limit}`);
await new Promise((r) => setTimeout(r, 500));
return { rows, nextOffset: offset + 1 };
}
The initial part of the code that we need to fetch data involves an API function that can provide us with data using a limit and offset. Here, we're not using a real API and are generating some fake data, but the result is the same. This function is asynchronous and returns an object with rows
and nextOffset
.
const AsyncList = () => {
const {
status,
data,
error,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery(
"projects",
(ctx) => fetchServerPage(10, ctx.pageParam),
{
getNextPageParam: (_lastGroup, groups) => groups.length,
}
);
const allRows = data ? data.pages.flatMap((d) => d.rows) : [];
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (
lastItem.index >= allRows.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
});
return 'Async List'
}
Here is our async component without markup. The goal here is to set up useInfiniteQuery
and useVirtualizer
. You already know that useVirtualizer
helps us work with virtual items. useInfiniteQuery
helps us load data based on limit and offset and cache it. The only difference here is that instead of providing mock data in useVirtualizer
, we use data from useInfiniteQuery
.
We've also added fetching pages when the component re-renders and doesn't have data. Now it's time to add markup.
const AsyncList = () => {
...
return (
<>
{status === "loading" ? (
<p>Loading...</p>
) : status === "error" ? (
<span>Error: {(error as Error).message}</span>
) : (
<div ref={parentRef} className="container">
<div
className="list"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
className={`row ${
virtualRow.index % 2 ? "list-item-odd" : "list-item-even"
}`}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{allRows[virtualRow.index]}
</div>
))}
</div>
</div>
)}
</>
);
};
Here we've written markup that looks pretty similar to the previous component. We render our virtual list but additionally display status
and error
.
The list is still functional, and we're successfully loading our async data.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done