Virtualizing a 50,000-Row × 200-Column Data Grid at 60FPS with TanStack Table + TanStack Virtual + React Query
While working on a BI dashboard, the moment the column count exceeded 150 and the row count surpassed 50,000, I experienced the browser genuinely freezing for the first time. I initially thought, "Can't we just use pagination?" — but the analysts insisted they needed to see all the data on one screen while also scrolling horizontally. No browser can handle millions of DOM cells inserted all at once.
In this post, I'll walk through the pattern of combining TanStack Table v8 + TanStack Virtual v3 + React Query v5 to virtualize both rows and columns simultaneously, with real code. I'll cover honestly how the three libraries interlock with each other, as well as the pitfalls you often encounter in practice. By the end, you'll be able to build a component that scrolls a 50,000-row × 200-column grid at 60FPS yourself.
Prerequisites for this post: React Hooks basics (useState, useEffect, useRef, useMemo), async/await. It's fine if you're new to React Query or TanStack Table.
Table of Contents
- Core Concepts — Role Division of the Three Libraries
- The Core Principle of Virtualization
- Example 1: Row Virtualization + Infinite Scroll
- Example 2: Simultaneous Row & Column Virtualization (Extension of Example 1)
- Example 3: Sticky Header + Fixed Columns (Extension of Example 2)
- Pros & Cons + Common Mistakes
- Closing Thoughts
Core Concepts
Role Division of the Three Libraries
The reason this pattern is powerful is that each library handles exactly its own responsibility. Nothing is crammed into a single library.
| Library | Domain | Core API |
|---|---|---|
@tanstack/react-query v5 |
Server data fetching, caching & synchronization | useInfiniteQuery |
@tanstack/react-table v8 |
Table state & logic (sorting, filtering, selection, etc.) + row model construction | useReactTable, getCoreRowModel |
@tanstack/react-virtual v3 |
DOM virtualization (controlling the actual rendering range) | useVirtualizer |
What is Headless UI? It's a design approach that provides only logic and state without imposing any markup or styles. TanStack Table doesn't render even a single
<table>tag — but that's precisely why it can be freely layered onto shadcn/ui, MUI, or any custom design system.
The Core Principle of Virtualization
The core of virtualization is simple: only what is visible on screen exists in the DOM.
If the viewport height is 800px and each row height is 48px, only about 17 rows actually exist in the DOM. The remaining 49,983 rows occupy space via CSS transform: translateY() but have no actual DOM nodes. When you scroll, TanStack Virtual calculates the indices currently in the viewport via getVirtualItems(), and React renders only those rows.
The data flow looks like this:
Total data: 50,000 rows
↓
React Query: fetches from server in chunks of 50 → managed as a page array
↓
TanStack Table: receives flatData, applies sorting/filtering → constructs row model
↓
TanStack Virtual: extracts only 17 row indices currently visible
↓
Actual DOM: only 17 <tr> elements existOne thing worth clarifying: TanStack Table constructs the row model; React Query only supplies the data. When first encountering this pattern, it's easy to assume React Query is involved in table state as well — but their roles are clearly separated.
Practical Application
Example 1: Row Virtualization + Infinite Scroll
This is the most commonly used pattern. It combines infinite scroll — which automatically loads the next page when you reach the bottom — with virtualization. Examples 2 and 3 are built on top of this code.
Let's start by defining the types. In a TypeScript strict environment, pinning down types from the start reduces the pain of compiler errors later.
// types.ts
type User = {
id: string
name: string
email: string
department: string
joinedAt: string
}
type FetchUsersResponse = {
rows: User[]
nextCursor: number | null
totalCount: number
}
async function fetchUsers({
page,
limit,
}: {
page: number
limit: number
}): Promise<FetchUsersResponse> {
const res = await fetch(`/api/users?page=${page}&limit=${limit}`)
if (!res.ok) throw new Error('fetch failed')
return res.json()
}// VirtualizedTable.tsx
import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useMemo, useEffect } from 'react'
const FETCH_SIZE = 50
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: 'Name', size: 150 },
{ accessorKey: 'email', header: 'Email', size: 200 },
{ accessorKey: 'department', header: 'Department', size: 120 },
{ accessorKey: 'joinedAt', header: 'Joined', size: 120 },
]
export function VirtualizedTable() {
const tableContainerRef = useRef<HTMLDivElement>(null)
// 1. React Query: fetch data from server in page units
const { data, fetchNextPage, hasNextPage, isFetching } =
useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam }) =>
fetchUsers({ page: pageParam as number, limit: FETCH_SIZE }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
})
// 2. Flatten the page array into a single flat array
const flatData = useMemo(
() => data?.pages.flatMap((page) => page.rows) ?? [],
[data]
)
const totalCount = data?.pages[0]?.totalCount ?? 0
// 3. TanStack Table: construct the row model
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
// Omitting these two options with server-side data throws off pagination calculations
manualPagination: true,
rowCount: totalCount,
})
const { rows } = table.getRowModel()
// 4. TanStack Virtual: render only the currently visible rows
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48,
overscan: 10,
})
const virtualRows = rowVirtualizer.getVirtualItems()
// 5. Detect scroll end → automatically fetch next page
useEffect(() => {
const lastItem = virtualRows.at(-1)
if (
lastItem &&
lastItem.index >= flatData.length - 1 &&
hasNextPage &&
!isFetching
) {
fetchNextPage()
}
}, [virtualRows, flatData.length, hasNextPage, isFetching, fetchNextPage])
const totalSize = rowVirtualizer.getTotalSize()
return (
<div
ref={tableContainerRef}
style={{ height: '600px', overflow: 'auto', position: 'relative' }}
>
<table style={{ display: 'grid' }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} style={{ display: 'flex' }}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ width: header.getSize() }}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody
style={{ height: `${totalSize}px`, position: 'relative' }}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
width: '100%',
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
)
})}
</tbody>
</table>
{isFetching && <div>Loading more...</div>}
</div>
)
}Key code highlights:
| Section | Description |
|---|---|
flatData + useMemo |
Uses pages.flatMap() to unroll the page array into a single row array. Without useMemo, using it inline creates a new array on every render, causing unnecessary recalculation in both TanStack Table and Virtual |
manualPagination: true |
Required when using server-side pagination. Without it, TanStack Table miscalculates page counts on the client, causing the next-page fetch to either loop infinitely or stop prematurely |
transform: translateY |
Positions rows via CSS transform instead of actual DOM position. Avoids layout reflow and is significantly more performant |
overscan: 10 |
Pre-renders 10 rows beyond the viewport. Prevents blank white space from appearing during fast scrolling |
Example 2: Simultaneous Row & Column Virtualization (Extension of Example 1)
Once columns exceed 50, horizontal virtualization becomes necessary too. In practice, a 200-column grid without horizontal virtualization yields less than half the benefit of vertical virtualization alone.
The React Query and flatData construction from Example 1 carries over unchanged; only the table and virtualization portions are extended. This time we use <div> instead of <table> tags — the reason is explained in the blockquote below.
When rendering headers, avoid index-based access like headers[virtualColumn.index]. If there are hidden columns or the column order changes, headers and cells will become misaligned. I initially accessed them by index and later, after adding a column-hide feature, hit a bug where headers were off by one cell from their corresponding cells — a surprisingly hard bug to track down. Mapping by column ID is much safer.
// BidirectionalVirtualizedTable.tsx
// The React Query + flatData section is identical to Example 1 — only table/virtualizer onward is shown below
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type Header,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useMemo } from 'react'
export function BidirectionalVirtualizedTable({
flatData, // same structure as flatData from Example 1
totalCount,
}: {
flatData: User[]
totalCount: number
}) {
const tableContainerRef = useRef<HTMLDivElement>(null)
const table = useReactTable({
data: flatData,
columns, // e.g. 200 columns (type: ColumnDef<User>[])
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: totalCount,
defaultColumn: { size: 150 },
})
const { rows } = table.getRowModel()
const visibleColumns = table.getVisibleLeafColumns()
// Map headers by column ID — safe ID-based lookup instead of index access
const headerByColumnId = useMemo(() => {
const leafHeaders =
table.getHeaderGroups().at(-1)?.headers ?? []
return new Map<string, Header<User, unknown>>(
leafHeaders.map((h) => [h.column.id, h])
)
}, [table])
// Row virtualizer (vertical)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 35,
overscan: 5,
})
// Column virtualizer (horizontal)
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: visibleColumns.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: (index) => visibleColumns[index].getSize(),
overscan: 3,
})
const virtualRows = rowVirtualizer.getVirtualItems()
const virtualColumns = columnVirtualizer.getVirtualItems()
const totalRowHeight = rowVirtualizer.getTotalSize()
const totalColWidth = columnVirtualizer.getTotalSize()
return (
<div
ref={tableContainerRef}
style={{ height: '600px', width: '100%', overflow: 'auto' }}
>
<div
style={{
height: `${totalRowHeight}px`,
width: `${totalColWidth}px`,
position: 'relative',
}}
>
{/* Header — pinned to top via position: sticky */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 1,
background: 'white',
}}
>
{virtualColumns.map((virtualColumn) => {
const col = visibleColumns[virtualColumn.index]
const header = headerByColumnId.get(col.id)
if (!header) return null
return (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
left: virtualColumn.start,
width: virtualColumn.size,
height: 35,
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
)
})}
</div>
{/* Body — renders only the intersection of visible rows and columns */}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
const visibleCells = row.getVisibleCells()
return virtualColumns.map((virtualColumn) => {
const cell = visibleCells[virtualColumn.index]
return (
<div
key={`${virtualRow.index}-${virtualColumn.index}`}
style={{
position: 'absolute',
top: virtualRow.start + 35, // header height offset
left: virtualColumn.start,
width: virtualColumn.size,
height: virtualRow.size,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
)
})
})}
</div>
</div>
)
}Why use
<div>instead of<table>tags? With simultaneous row and column virtualization, cells must be positioned withposition: absolute. The browser's<table>layout engine doesn't play well with absolute positioning, so a div-based grid is far more stable in this case. Row-only virtualization as in Example 1 can work with<table>+display: grid, but from bidirectional virtualization onward, switching to<div>is recommended.
Rendering scope with simultaneous virtualization: Only 17 rows × 8 columns = 136 cells exist in the DOM at any time. Even with 1,000 rows × 200 columns (200,000 cells), only 136 cells are painted on screen.
Example 3: Sticky Header + Fixed Columns (Extension of Example 2)
This pattern mimics real BI tools: the header stays fixed at all times, and the first column (e.g., name, ID) remains pinned during horizontal scroll. This adds only the fixed-column handling logic on top of Example 2's structure.
There are cases where position: sticky and transform-based virtualization in the same scroll container break the layout. I've seen many people get stuck at this point — the key is designing the scroll container hierarchy correctly.
[Scroll container: overflow: auto, position: relative]
└─ [Full-size div: total width/height, position: relative]
├─ [Sticky header: position: sticky, top: 0, zIndex: 2]
│ └─ [Virtual column headers: position: absolute, left: ...]
│ ※ transform must NOT be present inside this element
└─ [Body: position: relative, top: header height]
└─ [Virtual cells: position: absolute, top/left: ...]For position: sticky to work, none of the element's ancestors may have transform applied. If any ancestor has transform, that element becomes a new containing block and sticky stops working.
// Adding fixed-column handling to BidirectionalVirtualizedTable from Example 2
// Add sticky meta to column definitions
const columns: ColumnDef<User>[] = [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
size: 200,
meta: { sticky: true },
},
// ...remaining columns
]
// In rendering — replace the body rendering section from Example 2 with the following
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
const visibleCells = row.getVisibleCells()
return virtualColumns.map((virtualColumn) => {
const isSticky = virtualColumn.index === 0
const cell = visibleCells[virtualColumn.index]
return (
<div
key={`${virtualRow.index}-${virtualColumn.index}`}
style={{
// sticky columns use sticky instead of absolute
position: isSticky ? 'sticky' : 'absolute',
top: isSticky
? virtualRow.start + 35
: virtualRow.start + 35,
left: isSticky ? 0 : virtualColumn.start,
zIndex: isSticky ? 2 : 0,
width: virtualColumn.size,
height: virtualRow.size,
background: isSticky ? 'white' : 'transparent',
// ※ do NOT apply transform directly to sticky elements
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
)
})
})}Critical note on Sticky + Virtualization: No ancestor in the sticky element's chain may have
transformapplied. Even if virtualized rows move viatransform: translateYwithin the scroll container, the sticky element itself must control its position solely viaposition: sticky + top/left.
Pros & Cons Analysis
Advantages
Here's a summary of why you'd choose this pattern in practice:
| Item | Detail |
|---|---|
| Dramatic memory savings | With 50,000 rows, achievable memory usage drops from 136 MB → ~4.8 MB (based on TanStack Table PR #5927, simple 5-column data environment) |
| Smooth scrolling | Keeping DOM nodes within the viewport range enables sustained 60FPS scrolling |
| Flexible design integration | Headless design allows it to be composed with shadcn/ui, MUI, or any custom design system |
| Natural integration with server state | React Query's caching, refetching, and optimistic updates connect seamlessly with virtualized scrolling |
| Lightweight bundle | ~40KB combined across all three libraries — far lighter than fully-featured alternatives like AG Grid |
| Framework agnostic | The same pattern applies to Vue, Svelte, Solid, and Angular |
Honestly, the thing that makes the biggest real-world impact among these is design freedom. With a fully-featured grid library, making even a single custom cell renderer means digging through documentation for a long time. With this pattern, a cell is just a React component — you can put whatever you want in it.
Disadvantages and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Dynamic row heights | Variable-height cells can be measured with the measureElement option, but scroll position jitter may occur |
Fixed heights are recommended when possible. If unavoidable, the measureElement + scrollToIndex combination can mitigate it |
| Excessive overscan | overscan: 25 or above increases render overhead on low-to-mid-range devices |
Keeping it around overscan: 10 is appropriate |
| SEO disadvantage | Virtualized rows don't exist in the DOM, so crawlers can't index the data | If the data table needs to be indexed for SEO, consider SSR + server-rendering only the first page on initial render |
| Accessibility (a11y) limitations | Screen readers have limited navigation since not all rows are in the DOM | Additional work is required to add ARIA attributes like aria-rowcount and aria-rowindex |
| Initial implementation complexity | There is a learning cost to connecting the three libraries (shared scroll container ref, data flattening, etc.) | Starting from the official examples is a good baseline |
Among these, the one that caused the most real-world pain was the accessibility issue. If screen reader issues come up in QA after the feature is fully built, you end up having to retrofit all the ARIA attributes retroactively — so it's worth considering aria-rowcount and aria-rowindex from the start.
The Most Common Real-World Mistakes
-
Omitting both
manualPagination: trueandrowCounttogether — When using server-side data and leaving out these options, TanStack Table miscalculates page counts on the client side, causing the next-page fetch to either loop infinitely or stop. I overlooked this myself at first and spent quite a while debugging. -
Connecting the scroll container ref incorrectly — Both
rowVirtualizerandcolumnVirtualizermust havegetScrollElementpointing to the same ref. If they point to different containers, scroll calculations go out of sync. -
Missing memoization for
flatData— Usingpages.flatMap()inline withoutuseMemocreates a new array on every render, causing unnecessary recalculation in both TanStack Table and Virtual.
Closing Thoughts
The TanStack Table + TanStack Virtual + React Query combination is one of the most practical approaches in the current React ecosystem for handling large-scale tables. There are cases where a fully-featured solution like AG Grid is better. If features need to be built-in out of the box, if the team is short on frontend implementation resources, or if Excel-level interactions are required, AG Grid may be the faster choice. On the other hand, if design freedom, bundle size, and extensibility of custom logic matter more, this pattern is far more flexible. You can check the latest download trends for TanStack Virtual directly on npm trends.
Three steps you can take right now:
-
Run the official example directly on StackBlitz. Open TanStack Table's virtualized-infinite-scrolling example in your browser, and while scrolling, watch the Elements tab in DevTools — you can see with your own eyes how the DOM node count stays constant.
-
Apply the row virtualization code from Example 1 to your own project's data first. After running
pnpm add @tanstack/react-table @tanstack/react-virtual @tanstack/react-query, start by changingcolumnsand thefetchUserstype to match your API response. Column virtualization can be added as a next step. -
Compare before and after virtualization directly in the Chrome DevTools Performance tab. The DOM node count and memory usage graph during scrolling are also quite effective as material for persuading teammates.
Next post: Synchronizing TanStack Table's column filter and sort state with TanStack Router's URL parameters to build a bookmarkable data grid
References
- TanStack Table Virtualization Guide (Official)
- TanStack Virtual Official Site
- TanStack Table Virtualized Rows Official Example
- TanStack Table Virtualized Columns Official Example
- TanStack Table Virtualized Infinite Scrolling Official Example
- TanStack Virtual Virtualizer API Docs
- Building an Efficient Virtualized Table with TanStack Virtual and React Query with ShadCN | DEV Community
- Building Sticky Headers and Columns with TanStack Virtualizer | Medium
- React Virtualization Showdown: TanStack Virtualizer vs React-Window | Medium
- Making Tanstack Table 1000x faster with a 1 line change | JP Camara
- Infinite List with Tanstack: React Query & Virtual | Hashnode
- @tanstack/react-virtual vs react-virtualized vs react-window | npm trends