Web Development
ReactTanStack TableVirtualizationInfinite ScrollReact QueryPerformance

React Table Virtualization & Infinite Scroll: Building Fast Data Grids with TanStack (2026 Guide)

AO
Adrijan Omićević
·16 min read

# 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#

MetricGood targetWhy it matters
Rendered rows in DOM40 to 120Keeps layout and painting cheap
Scroll frame budget16.7 ms per frameNeeded for 60 fps scrolling
Server page size50 to 200 rowsKeeps payload reasonable and reduces round trips
Debounce for filters200 to 400 msPrevents request storms while typing
Stable row IDRequiredEnables 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:

  1. 1
    Table state and column logic with TanStack Table.
  2. 2
    Rendering only visible rows with TanStack Virtual.
  3. 3
    Data fetching and cache with React Query, using infinite queries for infinite scroll and mutations for edits.
ConcernLibraryNotes
Table logicTanStack TableHeadless, supports server-side sorting/filtering
VirtualizationTanStack VirtualWorks with variable row heights, overscan
Data fetchingReact QueryCaching, pagination, optimistic updates
URL syncNext.js router or your routerTreat 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#

RequirementVersionNotes
React18+Concurrent rendering behavior matters
TanStack Tablev8+APIs used here follow v8 patterns
TanStack Virtualv3+Virtualizer hook-based API
React Queryv5+Infinite queries and mutation APIs
TypeScriptRecommendedHelps 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#

FieldTypeExampleNotes
itemsarray[{ id, ... }]Must include stable id
nextCursorstring or null"eyJpZCI6..."Null means no more pages
totalnumber153204Optional, expensive for big datasets
metaobject{ tookMs: 42 }Useful for debugging

Sorting and filtering parameters#

Keep them explicit and serializable so you can sync to URL.

ParamExampleNotes
sortcreatedAt:descSingle sort is simplest
filters[status]activeUse stable keys
qacmeGlobal search
cursor...Cursor for infinite scroll
limit100Page 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.

TypeScript
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.

TypeScript
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 featureSettingWhy
SortingmanualSorting: trueServer owns order
FilteringmanualFiltering: trueServer owns filter results
Paginationhandled by React QueryInfinite pages, not table paging

Example configuration:

TypeScript
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.

TypeScript
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#

TypeScript
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.

TypeScript
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.

TypeScript
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:

  1. 1
    Reset to the first page.
  2. 2
    Scroll to top.
  3. 3
    Avoid 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:

TypeScript
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#

DecisionRecommendedWhy
Key byidStable across pagination and sorting
StorageuseState or external storeDepends on how global it is
DefaultemptyAvoid “select all” surprises
Bulk actionsoperate on IDsServer can accept ID lists

Example:

TypeScript
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.

ActionWhat it selectsImplementation
Select visibleOnly loaded itemsAdd IDs from flattened rows
Select all filteredEntire server resultServer-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.

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#

StatePut in URLNotes
Global search qYesDebounce updates
Column filtersYesOnly serializable primitives
SortingYesUse col:dir
CursorUsually noCursors are ephemeral; reset on reload
Selected IDsNoToo large and privacy-sensitive

Query string helpers#

Keep encoding simple. Use comma-separated strings, not JSON.

TypeScript
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.

TypeScript
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.

StepActionWhy
1Optimistically remove from cacheInstant UI
2Remove from selection setPrevent ghost selection
3Invalidate queryReconcile 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. 1

    Keying rows by index
    Use stable id keys everywhere. Index keys break selection, virtualization reuse, and can create visual glitches when sorting.

  2. 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. 3

    Triggering fetch on scroll events directly
    Scroll events fire frequently. Use the virtualizer range to decide when you are near the end.

  4. 4

    Unbounded query keys
    Do not put non-serializable objects in query keys. Keep params stable and shallow so caching works.

  5. 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 manualSorting and manualFiltering, 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.