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<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 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> ), }, ];
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>;
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" }} // π 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> ); }
π 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)