DEV Community

Ashikul Islam Nayeem
Ashikul Islam Nayeem

Posted on

Building an Efficient Virtualized Table with TanStack Virtual and React Query with ShadCN

When displaying large datasets in a table, performance and smooth scrolling become critical challenges. That's where TanStack Virtual (formerly known as react-virtual) and React Query come into play. In this guide, we'll walk through building a virtualized table that fetches paginated data and provides a seamless user experience.

Tanstack Virtual with Load More button

Step 1: Fetching Paginated Data with React Query
First, we need to fetch our data efficiently using React Query. We'll define a query that retrieves companies' data based on pagination.

const { data, isLoading, error, isFetching } = useQuery<CompanyResponse>({
  queryKey: ["companies", searchParameters.toString(), itemsPerPage],
  queryFn: () =>
    fetchCompanies(
      currentPage.toString(),
      itemsPerPage.toString(),
    ),
});

Enter fullscreen mode Exit fullscreen mode
  • queryKey ensures proper caching and refetching when parameters change.
  • queryFn is the function that actually fetches the data.
  • make a queryFn for fetching data

Step 2: Implementing a "Load More" Pagination

Instead of traditional pagination, we'll use a "Load More" approach that increases the number of items fetched.

const handleLoadMore = () => {
  setItemsPerPage((previous) => previous + PAGE_INCREMENT);
};
Enter fullscreen mode Exit fullscreen mode

This makes it feel like an infinite scroll experience without dealing with page numbers manually.

Step 3: Setting Up Virtualization with TanStack Virtual
Next, we use TanStack Virtual to render only the visible rows, dramatically improving performance.

const virtualizer = useVirtualizer({
  count: data?.companies.length || 0,
  estimateSize: () => 40, // Average row height
  getScrollElement: () => scrollContainerRef.current,
});

const virtualRows = virtualizer.getVirtualItems();
const visibleCompanies = virtualRows
  .map((virtualRow) => data?.companies[virtualRow.index])
  .filter(Boolean);

Enter fullscreen mode Exit fullscreen mode

Here:

  • count is the total number of companies we fetched.
  • estimateSize gives the virtualizer a rough idea of row height.
  • getScrollElement provides the scrollable container.

Step 4: Defining Table Columns
Now, let's define the table columns with appropriate headers and cell renderers.

const tableColumns: ColumnDef<Company | undefined>[] = [
  {
    accessorKey: "name",
    header: () => <div>Company Name</div>,
    cell: ({ row }) => <div>{row.original?.name}</div>,
  },
  {
    accessorKey: "phone",
    header: () => <div>Phone Number</div>,
    cell: ({ row }) => <div>{row.original?.phone}</div>,
  },
  {
    accessorKey: "email",
    header: () => <div>Email</div>,
    cell: ({ row }) => <div>{row.original?.email}</div>,
  },
  {
    accessorKey: "location",
    header: () => <div>Location</div>,
    cell: ({ row }) => <div>{row.original?.address.state}</div>,
  },
  {
    accessorKey: "products",
    header: () => <div>Products</div>,
    cell: ({ row }) => (
      <div className="flex items-center gap-2">
        <UserIcon /> {row.original?.productsCount}
      </div>
    ),
  },
  {
    accessorKey: "actions",
    header: () => <div>Actions</div>,
    cell: () => (
      <div className="flex gap-2">
        <button>Details</button>
      </div>
    ),
  },
];

Enter fullscreen mode Exit fullscreen mode

Step 5: Handling Loading and Error States
Before rendering the table, we need to handle loading, error, or empty states gracefully.

if (isLoading) return <LoadingSkeleton />;
if (error) return <div>Error loading data</div>;
if (!data) return <div>No data available</div>;

Enter fullscreen mode Exit fullscreen mode

Step 6: Rendering the Virtualized Table
Here comes the main part: rendering the virtualized list inside a scrollable container.

<section>
  <div
    ref={scrollContainerRef}
    className="relative h-[400px] overflow-auto rounded-md"
  >
    <div
      style={{
        height: virtualizer.getTotalSize(),
        position: "relative",
      }}
    >
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: "100%",
          transform: `translateY(${virtualRows[0]?.start ?? 0}px)`,
        }}
      >
        <CustomTable columns={tableColumns} data={visibleCompanies} />
      </div>
    </div>
  </div>
</section>

Enter fullscreen mode Exit fullscreen mode

Here’s what happens:

  • We create a scrollable container (overflow-auto) with a fixed height.
  • The total container height (getTotalSize()) matches the total rows' size.
  • Only the visible portion (translateY) moves according to the current scroll.

Step 7: Adding a Load More Button
At the bottom, we add a "Load More" button to fetch more data dynamically.

<section className="flex justify-center mt-4">
  <Button
    onClick={handleLoadMore}
    disabled={isFetching || (data && data.companies.length >= data.totalCount)}
  >
    {isFetching ? "Loading..." : "Load More"}
  </Button>
</section>

Enter fullscreen mode Exit fullscreen mode

By combining React Query for efficient data fetching and TanStack Virtual for rendering optimization, we've built a fast, scalable, and user-friendly table even for large datasets.

Key Takeaways:

  • Virtualization avoids rendering all rows at once, saving memory and improving performance.
  • Pagination with a "Load More" button makes loading large lists intuitive.
  • Loading and error handling ensures a smooth user experience.

Here is the ShadCN table Component

//custom table
"use client";

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function CustomTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div className="rounded-md border overflow-x-auto">
      <Table className="min-w-full table-fixed">
        <TableHeader className="bg-muted text-muted-foreground">
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead
                  key={header.id}
                  className="whitespace-nowrap px-4 py-2 text-left"
                  style={{ width: "150px" }} // πŸ‘ˆ FIX WIDTH HERE
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell
                    key={cell.id}
                    className="whitespace-nowrap px-4 py-2"
                    style={{ width: "150px" }} // πŸ‘ˆ FIX WIDTH HERE TOO
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Have any questions?
πŸ‘‰ Facing issues while implementing it?
πŸ‘‰ Got ideas for making it even better?

Drop your questions or thoughts in the comments below!
I'd love to hear what you're building and help out if I can. πŸš€πŸ’¬

Thanks for reading!

Top comments (0)