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. 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({ queryKey: ["companies", searchParameters.toString(), itemsPerPage], queryFn: () => fetchCompanies( currentPage.toString(), itemsPerPage.toString(), ), }); 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); }; 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); 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[] = [ { accessorKey: "name", header: () => Company Name, cell: ({ row }) => {row.original?.name}, }, { accessorKey: "phone", header: () => Phone Number, cell: ({ row }) => {row.original?.phone}, }, { accessorKey: "email", header: () => Email, cell: ({ row }) => {row.original?.email}, }, { accessorKey: "location", header: () => Location, cell: ({ row }) => {row.original?.address.state}, }, { accessorKey: "products", header: () => Products, cell: ({ row }) => ( {row.original?.productsCount} ), }, { accessorKey: "actions", header: () => Actions, cell: () => ( Details ), }, ]; Step 5: Handling Loading and Error States Before rendering the table, we need to handle loading, error, or empty states gracefully. if (isLoading) return ; if (error) return Error loading data; if (!data) return No data available; Step 6: Rendering the Virtualized Table Here comes the main part: rendering the virtualized list inside a scrollable container. 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. = data.totalCount)} > {isFetching ? "Loading..." : "Load More"} 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 { columns: ColumnDef[]; data: TData[]; } export function CustomTable({ columns, data, }: DataTableProps) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), }); return ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.colu

Apr 23, 2025 - 19:00
 0
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(),
    ),
});

  • 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);
};

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);

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 Namediv>,
    cell: ({ row }) => <div>{row.original?.name}div>,
  },
  {
    accessorKey: "phone",
    header: () => <div>Phone Numberdiv>,
    cell: ({ row }) => <div>{row.original?.phone}div>,
  },
  {
    accessorKey: "email",
    header: () => <div>Emaildiv>,
    cell: ({ row }) => <div>{row.original?.email}div>,
  },
  {
    accessorKey: "location",
    header: () => <div>Locationdiv>,
    cell: ({ row }) => <div>{row.original?.address.state}div>,
  },
  {
    accessorKey: "products",
    header: () => <div>Productsdiv>,
    cell: ({ row }) => (
      <div className="flex items-center gap-2">
        <UserIcon /> {row.original?.productsCount}
      div>
    ),
  },
  {
    accessorKey: "actions",
    header: () => <div>Actionsdiv>,
    cell: () => (
      <div className="flex gap-2">
        <button>Detailsbutton>
      div>
    ),
  },
];

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 datadiv>;
if (!data) return <div>No data availablediv>;

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>

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>

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" }} //