# What You’ll Learn#
React table virtualization is the difference between a grid that feels instant and one that drops frames, pegs the CPU, and turns user interactions into a laggy mess.
In this guide you’ll build a production-ready pattern with TanStack Table plus TanStack Virtual and React Query: virtualized rendering, infinite scroll backed by server-side pagination, server-side sorting and filtering, URL-synced state, selection state that survives refetches, and optimistic updates for edits and bulk actions.
You’ll also see where teams commonly regress performance and how to prevent it using profiling and correct memoization patterns. For deeper React performance diagnostics, see React performance profiling, memoization and rendering patterns.
# Why Virtualization Matters for Data Grids#
Rendering large datasets is not “just a fetch problem”. It is primarily a DOM problem.
Even modern browsers struggle when you render thousands of row nodes with multiple cells, icons, inputs, and conditional formatting. A grid with 10,000 rows and 12 columns can easily produce 120,000 cells. If each cell averages only 2 DOM nodes, that is around 240,000 nodes, before you add wrappers and interactive components.
Virtualization fixes this by rendering only what’s visible plus a small overscan buffer. In practice, that means a steady DOM size like 40 to 120 rows, even if the dataset has 200,000 rows.
Performance targets you should aim for#
| Metric | Good target | Why it matters |
|---|---|---|
| Rendered rows in DOM | 40 to 120 | Keeps layout and painting cheap |
| Scroll frame budget | 16.7 ms per frame | Needed for 60 fps scrolling |
| Server page size | 50 to 200 rows | Keeps payload reasonable and reduces round trips |
| Debounce for filters | 200 to 400 ms | Prevents request storms while typing |
| Stable row ID | Required | Enables selection persistence and correct cache updates |
🎯 Key Takeaway: Virtualization reduces DOM work, not network work. Pair it with server-side pagination and caching to solve both halves of the problem.
# Architecture Overview: TanStack Table, Virtual, and React Query#
A fast grid typically has three layers:
- 1Table state and column logic with TanStack Table.
- 2Rendering only visible rows with TanStack Virtual.
- 3Data fetching and cache with React Query, using infinite queries for infinite scroll and mutations for edits.
Recommended stack#
| Concern | Library | Notes |
|---|---|---|
| Table logic | TanStack Table | Headless, supports server-side sorting/filtering |
| Virtualization | TanStack Virtual | Works with variable row heights, overscan |
| Data fetching | React Query | Caching, pagination, optimistic updates |
| URL sync | Next.js router or your router | Treat URL as a source of truth |
If your app uses the Next.js App Router, your URL sync strategy should align with how you already handle server components and client components. If you are choosing between data libraries, read React Query vs SWR in Next.js App Router and pick one consistent approach.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| React | 18+ | Concurrent rendering behavior matters |
| TanStack Table | v8+ | APIs used here follow v8 patterns |
| TanStack Virtual | v3+ | Virtualizer hook-based API |
| React Query | v5+ | Infinite queries and mutation APIs |
| TypeScript | Recommended | Helps keep row IDs and models correct |
# Step 1: Model Your Server API for Pagination, Sorting, and Filtering#
Infinite scroll is a UI behavior. Your server should still paginate.
Use cursor pagination where possible, because it performs better at scale and avoids deep offset costs. For admin dashboards or smaller datasets, offset pagination can still be acceptable, but cursor pagination is the safer default when rows can reach hundreds of thousands.
Suggested API shape#
| Field | Type | Example | Notes |
|---|---|---|---|
items | array | [{ id, ... }] | Must include stable id |
nextCursor | string or null | "eyJpZCI6..." | Null means no more pages |
total | number | 153204 | Optional, expensive for big datasets |
meta | object | { tookMs: 42 } | Useful for debugging |
Sorting and filtering parameters#
Keep them explicit and serializable so you can sync to URL.
| Param | Example | Notes |
|---|---|---|
sort | createdAt:desc | Single sort is simplest |
filters[status] | active | Use stable keys |
q | acme | Global search |
cursor | ... | Cursor for infinite scroll |
limit | 100 | Page size |
⚠️ Warning: Do not send “table state blobs” to the server. Always send explicit sort keys and filter values. It keeps APIs stable and prevents client refactors from breaking backend logic.
# Step 2: Set Up React Query Infinite Query for Server-Side Pagination#
The goal is to fetch pages and present them as one continuous list for the virtualizer.
Here is a practical pattern for cursor pagination using useInfiniteQuery. Keep the query key stable and include sorting and filters so cache entries are correct.
import { useInfiniteQuery } from '@tanstack/react-query';
type RowItem = { id: string; name: string; status: string; updatedAt: string };
type Page = { items: RowItem[]; nextCursor: string | null };
function useGridData(params: {
sort: string;
filters: Record<string, string | undefined>;
q: string;
limit: number;
}) {
return useInfiniteQuery({
queryKey: ['grid', params],
initialPageParam: null as string | null,
queryFn: async ({ pageParam }) => {
const url = new URL('/api/items', window.location.origin);
url.searchParams.set('sort', params.sort);
url.searchParams.set('q', params.q);
url.searchParams.set('limit', String(params.limit));
if (pageParam) url.searchParams.set('cursor', pageParam);
for (const [k, v] of Object.entries(params.filters)) {
if (v) url.searchParams.set(`filters[${k}]`, v);
}
const res = await fetch(url.toString());
if (!res.ok) throw new Error('Failed to load');
return (await res.json()) as Page;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 30_000,
});
}Flatten pages for the table#
TanStack Table wants an array. Flatten the pages and keep it memoized.
import { useMemo } from 'react';
function useFlatRows(data: { pages?: { items: RowItem[] }[] } | undefined) {
return useMemo(() => {
const pages = data?.pages ?? [];
return pages.flatMap((p) => p.items);
}, [data]);
}For larger apps, treat cache and invalidation as a first-class design concern. The most common scaling issues are “refetch storms”, incorrect query keys, and mutation responses that do not update the right cache entry. See React Query cache invalidation, pagination, and mutations at scale.
# Step 3: Configure TanStack Table for Server-Side Sorting and Filtering#
When you sort and filter server-side, you must disable client-side row models for those operations. TanStack Table supports this cleanly.
Minimal table setup#
| Table feature | Setting | Why |
|---|---|---|
| Sorting | manualSorting: true | Server owns order |
| Filtering | manualFiltering: true | Server owns filter results |
| Pagination | handled by React Query | Infinite pages, not table paging |
Example configuration:
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type SortingState,
type ColumnFiltersState,
} from '@tanstack/react-table';
function useServerTable(args: {
data: RowItem[];
columns: ColumnDef<RowItem>[];
sorting: SortingState;
columnFilters: ColumnFiltersState;
onSortingChange: (s: SortingState) => void;
onColumnFiltersChange: (f: ColumnFiltersState) => void;
}) {
return useReactTable({
data: args.data,
columns: args.columns,
state: {
sorting: args.sorting,
columnFilters: args.columnFilters,
},
onSortingChange: args.onSortingChange,
onColumnFiltersChange: args.onColumnFiltersChange,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualFiltering: true,
});
}Converting TanStack state to API params#
Keep the mapping predictable. For example, allow one active sort.
function sortingToSortParam(sorting: { id: string; desc: boolean }[]) {
if (!sorting?.length) return 'updatedAt:desc';
const s = sorting[0];
return `${s.id}:${s.desc ? 'desc' : 'asc'}`;
}💡 Tip: Keep server sort keys equal to column accessor keys. Every translation layer is another place for bugs and another reason URL sync becomes fragile.
# Step 4: Add TanStack Virtual for Row Virtualization#
You virtualize the rendered rows, not the fetched pages. The infinite query can hold thousands of items, while the DOM holds only the visible slice.
The key is measuring the scroll container and asking the virtualizer which indexes to render.
Virtualizer setup#
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function useRowVirtualizer(rowCount: number) {
const parentRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 8,
});
return { parentRef, rowVirtualizer };
}Rendering virtual rows with absolute positioning#
This pattern keeps scrolling native and smooth.
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// Inside render:
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: totalSize, position: 'relative' }}>
{virtualItems.map((v) => {
const row = table.getRowModel().rows[v.index];
return (
<div
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${v.start}px)`,
}}
>
{/* render cells */}
</div>
);
})}
</div>
</div>Variable row heights#
If rows can wrap text or expand, switch to measurement-based sizing. You can measure each row and call rowVirtualizer.measureElement.
If you want stable performance, keep row heights fixed for the “normal” state, and move expansion to a detail panel outside the virtualized list.
ℹ️ Note: Virtualization and sticky headers are compatible, but easiest when the header is outside the scroll container and only the body is virtualized.
# Step 5: Infinite Scroll Trigger Based on Virtualizer Range#
Do not trigger fetch on scroll pixel values. Trigger on “near end of rendered range”.
When the last virtual row index approaches the loaded rows length, fetch the next page.
import { useEffect } from 'react';
function useFetchNextOnScroll(args: {
virtualItems: { index: number }[];
rowsLength: number;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}) {
useEffect(() => {
const last = args.virtualItems[args.virtualItems.length - 1];
if (!last) return;
const threshold = 15;
const shouldLoad =
last.index >= args.rowsLength - 1 - threshold &&
args.hasNextPage &&
!args.isFetchingNextPage;
if (shouldLoad) args.fetchNextPage();
}, [
args.virtualItems,
args.rowsLength,
args.hasNextPage,
args.isFetchingNextPage,
args.fetchNextPage,
]);
}Handling “sort or filter changed” reset#
When sort or filters change, you want to:
- 1Reset to the first page.
- 2Scroll to top.
- 3Avoid showing stale items mixed with new order.
You get this mostly for free if the query key includes sort and filters, because React Query creates a new cache entry and the old pages won’t be appended into the new query.
Also explicitly scroll to top:
function scrollToTop(parent: HTMLDivElement | null) {
if (parent) parent.scrollTo({ top: 0 });
}# Step 6: Preserve Selection State Across Pages, Refetches, and Sorting#
Selection state breaks when it is tied to row index. It must be tied to a stable row ID.
Store selection in a Set keyed by row.id, independent of the currently loaded pages.
Selection store pattern#
| Decision | Recommended | Why |
|---|---|---|
| Key by | id | Stable across pagination and sorting |
| Storage | useState or external store | Depends on how global it is |
| Default | empty | Avoid “select all” surprises |
| Bulk actions | operate on IDs | Server can accept ID lists |
Example:
import { useCallback, useMemo, useState } from 'react';
function useRowSelection() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const toggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const isSelected = useCallback(
(id: string) => selectedIds.has(id),
[selectedIds]
);
const selectedCount = selectedIds.size;
return { selectedIds, toggle, isSelected, selectedCount, setSelectedIds };
}Select all visible vs select all filtered#
Be explicit in UI, because they mean different things.
| Action | What it selects | Implementation |
|---|---|---|
| Select visible | Only loaded items | Add IDs from flattened rows |
| Select all filtered | Entire server result | Server-side flag or query-based selection |
For “select all filtered”, do not pull all IDs to the client. Use a server-side bulk operation that applies to the current filter query, and keep a client-side “excluded IDs” set if you need exceptions.
⚠️ Warning: “Select all” on infinite scroll often becomes a hidden performance bug where the client tries to accumulate tens of thousands of IDs. Prefer server-side bulk operations.
# Step 7: URL Sync for Sorting, Filters, and Search#
URL sync makes the grid shareable and keeps state across refreshes. It also reduces internal state bugs because you have a single source of truth.
What to put in the URL#
| State | Put in URL | Notes |
|---|---|---|
Global search q | Yes | Debounce updates |
| Column filters | Yes | Only serializable primitives |
| Sorting | Yes | Use col:dir |
| Cursor | Usually no | Cursors are ephemeral; reset on reload |
| Selected IDs | No | Too large and privacy-sensitive |
Query string helpers#
Keep encoding simple. Use comma-separated strings, not JSON.
function encodeFilters(filters: Record<string, string | undefined>) {
return Object.entries(filters)
.filter(([, v]) => Boolean(v))
.map(([k, v]) => `${k}:${encodeURIComponent(String(v))}`)
.join(',');
}
function decodeFilters(value: string) {
const out: Record<string, string> = {};
if (!value) return out;
for (const part of value.split(',')) {
const [k, v] = part.split(':');
if (k && v) out[k] = decodeURIComponent(v);
}
return out;
}Debounced URL updates#
Update URL when the user stops typing, not on every keystroke. Debouncing also reduces query churn because query keys often depend on those params.
If you’re unsure whether rerenders are coming from URL state or table state, profile it. The most common mistake is “state stored in too many places”, which forces extra renders and breaks memoization. The patterns in React performance profiling, memoization and rendering patterns help you identify the exact component causing the churn.
# Step 8: Optimistic Updates for Edits and Bulk Actions#
Optimistic updates make grids feel immediate. The key is updating every page in the infinite query cache, not just the currently visible slice.
Updating an item across pages#
This mutation updates the cached infinite query data by mapping through pages.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateItem(params: { sort: string; filters: any; q: string; limit: number }) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; patch: Partial<RowItem> }) => {
const res = await fetch(`/api/items/${input.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input.patch),
});
if (!res.ok) throw new Error('Update failed');
return (await res.json()) as RowItem;
},
onMutate: async (input) => {
const key = ['grid', params] as const;
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<any>(key);
qc.setQueryData(key, (old: any) => {
if (!old?.pages) return old;
return {
...old,
pages: old.pages.map((p: any) => ({
...p,
items: p.items.map((it: RowItem) =>
it.id === input.id ? { ...it, ...input.patch } : it
),
})),
};
});
return { previous, key };
},
onError: (_err, _input, ctx) => {
if (ctx?.previous) qc.setQueryData(ctx.key, ctx.previous);
},
onSettled: (_data, _err, _input, ctx) => {
if (ctx?.key) qc.invalidateQueries({ queryKey: ctx.key });
},
});
}Optimistic delete with selection cleanup#
After deleting, remove the ID from selection, and remove from cached pages.
| Step | Action | Why |
|---|---|---|
| 1 | Optimistically remove from cache | Instant UI |
| 2 | Remove from selection set | Prevent ghost selection |
| 3 | Invalidate query | Reconcile with server |
Keep mutation code small and predictable. For more patterns, including pagination-aware invalidation rules, see React Query cache invalidation, pagination, and mutations at scale.
💡 Tip: If an edit changes sort order, optimistic updates can temporarily show the row in the “wrong” place. For sortable columns, prefer a quick invalidate after mutate, or apply optimistic update and accept a brief mismatch until refetch.
# Common Pitfalls and How to Avoid Them#
- 1
Keying rows by index
Use stableidkeys everywhere. Index keys break selection, virtualization reuse, and can create visual glitches when sorting. - 2
Mixing client-side and server-side sorting
If server sorts, disable client sort row models and keep table sorting state purely as a server param. - 3
Triggering fetch on scroll events directly
Scroll events fire frequently. Use the virtualizer range to decide when you are near the end. - 4
Unbounded query keys
Do not put non-serializable objects in query keys. Keep params stable and shallow so caching works. - 5
Rendering heavy cell components inside a virtualized list
Virtualization reduces DOM size, but expensive cell renders can still hurt. Memoize cell renderers and avoid per-cell closures where possible. Use the profiling workflow in React performance profiling, memoization and rendering patterns.
# Key Takeaways#
- Use React table virtualization to cap DOM size to a small visible window, even when data size is in the tens of thousands.
- Treat infinite scroll as a UI layer on top of server-side cursor pagination using React Query infinite queries.
- Implement server-side sorting and filtering with
manualSortingandmanualFiltering, and map table state to explicit API params. - Preserve selection by storing it in a Set of stable row IDs, never by row index, and handle “select all filtered” server-side.
- Sync search, filters, and sorting to the URL query string so the grid is shareable and resilient across reloads.
- Use optimistic updates by patching every page in the infinite query cache, then invalidate to reconcile with server truth.
# Conclusion#
A fast data grid is a system: virtualization for rendering, pagination and caching for data, and predictable state management for selection, URL sync, and mutations.
If you want Samioda to implement a production-grade TanStack grid in your React or Next.js app, including server-side filtering and sorting, URL-synced state, and reliable optimistic updates, contact us via samioda.com and share your dataset size, required columns, and backend constraints.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js Real-Time Features: WebSockets vs SSE vs Supabase Realtime (When to Use What)
A practical comparison of Next.js real-time options—WebSockets, Server-Sent Events, and Supabase Realtime—covering hosting constraints, scalability, auth, cost, and which to use for chat, dashboards, notifications, and collaborative editing.
Next.js Rate Limiting & Bot Protection: Patterns for APIs, Server Actions, and Edge (2026 Guide)
Practical Next.js rate limiting patterns for Route Handlers, Server Actions, and Edge runtime — with token bucket strategies, Redis-backed limits, WAF/CDN rules, monitoring, and false-positive mitigation.
Next.js SaaS Onboarding Checklist: Accounts, Permissions, Emails, and Trials (App Router, 2026)
A production-ready Next.js SaaS onboarding checklist covering authentication, organizations, invites, RBAC, transactional emails, and trial-to-paid conversion with practical patterns, libraries, and pitfalls.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Query at Scale: Cache Invalidation, Pagination, and Mutation Patterns for Real Apps
React Query cache invalidation best practices for real-world apps: scalable query key design, invalidation strategy, optimistic updates, infinite queries, and background refetching in Next.js App Router.
React Performance in 2026: Profiling, Memoization, and Rendering Patterns That Actually Work
A practical step-by-step guide to React performance profiling and memoization in 2026: how to diagnose slow UIs with React DevTools Profiler and why-did-you-render, pick the right rendering patterns, and avoid premature optimization.
React Query vs SWR in Next.js App Router: When to Use Which (and How to Avoid Double Fetching)
A practical 2026 comparison of React Query and SWR inside Next.js App Router — caching models, SSR and RSC compatibility, mutations, optimistic updates, DX, and proven patterns to prevent double fetching.