The Challenge of Large Datasets in React
Most frontend developers eventually hit a wall with data tables. When you are mapping over 50 rows, a simple .map() works perfectly. However, once your dataset grows to 1,000 or 10,000 records, the standard approach fails. The browser starts to stutter, interactions feel like they are lagging, and the DOM becomes too heavy to manage.
TanStack Table v8 is the go-to choice for solving these performance bottlenecks. It is a “headless” library. This means it manages the logic, state, and API for your table without forcing a specific UI on you. You keep total control over your HTML and CSS. In production environments, I have seen this approach keep interfaces snappy even when processing datasets exceeding 10,000 rows.
Step 1: Installation and Project Setup
First, grab the core table package. Since we want to handle massive amounts of data smoothly, we will also include the virtualization package from the same ecosystem.
npm install @tanstack/react-table @tanstack/react-virtual
If you prefer yarn or pnpm, use these commands instead:
yarn add @tanstack/react-table @tanstack/react-virtual
# or
pnpm add @tanstack/react-table @tanstack/react-virtual
Version 8 is built with TypeScript from the ground up. This provides excellent type safety, helping you catch potential bugs during development rather than discovering them after you deploy.
Step 2: Configuration and Core Logic
The core strength of TanStack Table v8 is its modularity. You only import the features you actually need, which keeps your bundle size small. Let’s set up a configuration that handles sorting and filtering.
Defining Columns and Data
Start by defining your data structure. We use the createColumnHelper to keep our cell renderers clean and type-safe.
import { createColumnHelper } from '@tanstack/react-table';
type User = {
id: number;
firstName: string;
lastName: string;
email: string;
age: number;
status: string;
};
const columnHelper = createColumnHelper<User>();
const columns = [
columnHelper.accessor('id', {
header: () => <span>ID</span>,
cell: info => info.getValue(),
}),
columnHelper.accessor('firstName', {
header: 'First Name',
}),
columnHelper.accessor('lastName', {
header: 'Last Name',
}),
columnHelper.accessor('email', {
header: 'Email',
}),
columnHelper.accessor('age', {
header: 'Age',
}),
];
Implementing the Table Hook
Think of the useReactTable hook as the brain of your component. You wire up your sorting and filtering models here. Because the library is modular, you must explicitly provide the row models you intend to use.
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
SortingState,
} from '@tanstack/react-table';
import { useState } from 'react';
function DataTable({ data }) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: {
sorting,
globalFilter,
},
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<div className="p-4">
<input
value={globalFilter ?? ''}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search all columns..."
className="mb-4 p-2 border rounded"
/>
<table>
{/* Headers and rows go here */}
</table>
</div>
);
}
Boosting Speed with Virtualization
Rendering 5,000 rows at once creates 5,000 <tr> elements. A table with 15 columns would result in 75,000 DOM nodes, which can crash mobile browsers. Virtualization solves this by only rendering the rows currently visible in the viewport.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const tableContainerRef = useRef<HTMLDivElement>(null);
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 35, // Average height of a row in pixels
overscan: 10, // Pre-render 10 rows for smoother scrolling
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
In your JSX, apply an absolute position to the rows using the start value from the virtualizer. This tricks the browser into showing a large scroll area while the DOM stays lightweight.
Step 3: Verification and Monitoring
After setting up virtualization, you should confirm the performance gains are real. I typically follow a simple checklist to ensure the implementation is effective.
Check the DOM Node Count
Open Chrome DevTools and look at the Elements tab. Scroll through your list. If you have 10,000 items but only see roughly 30 <tr> elements in the DOM at once, your virtualization is working. This significantly lowers memory usage.
Monitor Component Re-renders
Use the React Profiler to record a session while filtering or sorting. If individual cells re-render unnecessarily, your performance will suffer. To fix this, define your column objects outside the component or wrap them in useMemo.
Avoid These Common Pitfalls
- Missing CSS: Virtualization fails if the parent container lacks a fixed height and
overflow: auto. - Unstable Columns: Defining columns inside the component body without memoization forces the table to reset on every render.
- Heavy Logic: Keep your accessor functions fast. Move complex data transformations to the backend or process them before they reach the table state.
By following these steps, you build a data grid that feels responsive and professional. Users can scroll through massive datasets without the UI locking up. This setup also provides a clean foundation for adding features like column resizing or row grouping later on.

