DEV Community

Ashikul Islam Nayeem
Ashikul Islam Nayeem

Posted on

2

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!

Tutorial image

Next.js Tutorial 2025 - Build a Full Stack Social App

In this 4-hour hands-on tutorial, Codesistency walks you through the process of building a social platform from scratch with Next.js (App Router), React, Prisma ORM, Clerk for authentication, Neon for PostgreSQL hosting, Tailwind CSS, Shadcn UI, and UploadThing for image uploads.

Watch the full video ➡

Top comments (0)

Redis image

Short-term memory for faster
AI agents

AI agents struggle with latency and context switching. Redis fixes it with a fast, in-memory layer for short-term context—plus native support for vectors and semi-structured data to keep real-time workflows on track.

Start building

👋 Kindness is contagious

Delve into a trove of insights in this thoughtful post, celebrated by the welcoming DEV Community. Programmers of every stripe are invited to share their viewpoints and enrich our collective expertise.

A simple “thank you” can brighten someone’s day—drop yours in the comments below!

On DEV, exchanging knowledge lightens our path and forges deeper connections. Found this valuable? A quick note of gratitude to the author can make all the difference.

Get Started