React Virtual List: Revolutionizing List-Making

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.

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.

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.

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.

result sync

Our container is only 500px in height, and we have a virtual list inside.

performance

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.

async

The list is still functional, and we're successfully loading our async data.

Ready to elevate your React testing skills? If you're looking to master both unit testing and end-to-end testing for your React applications, I highly recommend checking out my React Testing: Unit Testing React and E2E Testing Course. This course will give you the expertise to confidently test every part of your React apps.

📚 Source code of what we've done