Web Development
ReactTanStack TableTanStack VirtualPerformanceUXData TablesReact Query

React Data Table Patterns at Scale: Virtualization, Column Pinning, Filters, and Export (TanStack Table + Virtual)

AO
Adrijan Omićević
·15 min read

# What You'll Build#

This guide covers React data table patterns that hold up when your table grows from a few hundred rows to tens of thousands. You’ll implement a scalable architecture using TanStack Table for state and TanStack Virtual for windowing, with patterns for column pinning, debounced filters, server-side sorting and pagination, and robust exports.

If you’re already doing infinite scroll or windowing, start with these related deep-dives and come back here to unify the patterns into a single table architecture:

# Prerequisites#

RequirementVersionNotes
React18+Concurrent rendering helps with perceived responsiveness
TanStack Table8+Guide assumes v8 patterns and APIs
TanStack Virtual3+For row virtualization
TanStack Query5+For server state, caching, and cancellation
TypeScriptRecommendedTables become easier to maintain with typed row models

ℹ️ Note: Table performance is dominated by DOM work. A “fast” API is still not enough if you render 10,000 rows with complex cell components. Virtualization often gives the biggest win because it reduces DOM nodes by orders of magnitude.

# Core Architecture: One Source of Truth for Table State#

At scale, most table bugs come from state duplication: the URL says one thing, local state says another, and the server query uses a third set of parameters. A reliable pattern is to treat table state as a single serializable object, then derive everything else from it.

Table state model#

Use a single tableState shape that matches TanStack Table’s state plus server-specific fields. Keep it serializable so you can store it in the URL, localStorage, or share it across tabs.

State sliceExample fieldsUsed for
PaginationpageIndex, pageSizeServer query offset and UI
Sortingid, descServer sorting and UI indicators
Column filterscolumnId, valueServer filtering, filter UI
Global searchqueryServer query, search input
Column pinningleft, right arraysUX for wide tables
Column visibilityhidden setPersonalization
Row selectionselectedRowIdsBulk actions and exports

A practical default is to keep visual preferences in localStorage and query-affecting state in URL parameters. That makes links shareable and back-button friendly.

A minimal typed state container#

TypeScript
type TableQueryState = {
  pageIndex: number;
  pageSize: number;
  sorting: Array<{ id: string; desc: boolean }>;
  columnFilters: Array<{ id: string; value: string | number | boolean | string[] }>;
  globalQuery: string;
};
 
type TableUiState = {
  columnPinning: { left: string[]; right: string[] };
  columnVisibility: Record<string, boolean>;
};
 
type DataTableState = {
  query: TableQueryState;
  ui: TableUiState;
};

Use a reducer so updates are explicit and batched. It’s easier to test than multiple setters, and it prevents “half-updated” states when you change pagination and sorting together.

TypeScript
type Action =
  | { type: "setPage"; pageIndex: number }
  | { type: "setPageSize"; pageSize: number }
  | { type: "setSorting"; sorting: TableQueryState["sorting"] }
  | { type: "setGlobalQuery"; globalQuery: string }
  | { type: "setColumnFilters"; columnFilters: TableQueryState["columnFilters"] }
  | { type: "setColumnPinning"; columnPinning: TableUiState["columnPinning"] }
  | { type: "setColumnVisibility"; columnVisibility: TableUiState["columnVisibility"] };
 
function reducer(state: DataTableState, action: Action): DataTableState {
  switch (action.type) {
    case "setPage":
      return { ...state, query: { ...state.query, pageIndex: action.pageIndex } };
    case "setPageSize":
      return { ...state, query: { ...state.query, pageSize: action.pageSize, pageIndex: 0 } };
    case "setSorting":
      return { ...state, query: { ...state.query, sorting: action.sorting, pageIndex: 0 } };
    case "setGlobalQuery":
      return { ...state, query: { ...state.query, globalQuery: action.globalQuery, pageIndex: 0 } };
    case "setColumnFilters":
      return { ...state, query: { ...state.query, columnFilters: action.columnFilters, pageIndex: 0 } };
    case "setColumnPinning":
      return { ...state, ui: { ...state.ui, columnPinning: action.columnPinning } };
    case "setColumnVisibility":
      return { ...state, ui: { ...state.ui, columnVisibility: action.columnVisibility } };
    default:
      return state;
  }
}

🎯 Key Takeaway: Reset pageIndex to zero whenever filters or sorting change. Not doing this is a common source of empty pages and “missing data” bug reports.

# Server-side Pagination and Sorting That Scales#

Client-side sorting and filtering are convenient, but they don’t scale if you have millions of records. Server-side patterns keep responses predictable and reduce memory use.

API contract#

Avoid ad-hoc query parameters. Use a clear contract that matches the table state.

ParameterExampleNotes
page0Zero-based page index is easiest with TanStack
pageSize50Keep a reasonable max, e.g. 200
sortcreatedAt:descSupport multiple sorts if needed
filtersstatus:activePrefer JSON encoding for complex filters
qjohnGlobal query string

If your filters are complex, send them as JSON in the request body for POST-based queries, or encode them as a compact string. Keep it consistent across list queries and exports.

React Query pattern for table data#

Use query keys that include only query-affecting state. UI preferences like pinned columns should not refetch data.

TypeScript
import { useQuery } from "@tanstack/react-query";
 
function useUsersTable(query: TableQueryState) {
  return useQuery({
    queryKey: ["users", query],
    queryFn: async ({ signal }) => {
      const res = await fetch("/api/users/search", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(query),
        signal,
      });
      if (!res.ok) throw new Error("Request failed");
      return res.json() as Promise<{ rows: unknown[]; total: number }>;
    },
    placeholderData: (prev) => prev,
    staleTime: 10_000,
  });
}

placeholderData keeps old rows visible while fetching new ones, which reduces layout shift and makes filtering feel faster.

⚠️ Warning: If you include non-serializable values in the query key, cache behavior gets unpredictable. Keep query plain JSON and stable, or normalize it into a string.

Manual mode in TanStack Table#

When the server is the source of truth, set manual modes and provide rowCount.

TanStack Table optionRecommended valueWhy
manualPaginationtrueServer decides pages
manualSortingtrueServer decides sorting
manualFilteringtrueServer applies filters
pageCount or rowCountfrom API totalEnables correct pagination UI

This prevents TanStack Table from doing expensive client operations that don’t match server results.

# Debounced Filters Without UX Lag or Request Spam#

Filters are where UX and performance often break first. Without debouncing, every keystroke triggers a request. With aggressive debouncing, the UI feels unresponsive. The right goal is: the input updates instantly, the query updates slightly later.

Pattern: local input state, debounced commit#

Keep the input controlled by local state, and only commit to table query state after a debounce.

TypeScript
function useDebouncedValue<T>(value: T, delayMs: number) {
  const [debounced, setDebounced] = React.useState(value);
 
  React.useEffect(() => {
    const t = window.setTimeout(() => setDebounced(value), delayMs);
    return () => window.clearTimeout(t);
  }, [value, delayMs]);
 
  return debounced;
}

Then apply it:

TypeScript
const [searchInput, setSearchInput] = React.useState(state.query.globalQuery);
const debouncedSearch = useDebouncedValue(searchInput, 300);
 
React.useEffect(() => {
  dispatch({ type: "setGlobalQuery", globalQuery: debouncedSearch });
}, [debouncedSearch]);

A 250 to 400 millisecond debounce is a common starting point. For “typeahead” experiences, use 150 to 250 milliseconds, but make sure requests are cancelled via the signal from React Query.

💡 Tip: Add a clear “Search” button for power users who prefer explicit commits, and still keep debouncing enabled. This reduces frustration for users on high-latency connections.

Filter value normalization#

Normalize filters so the server receives consistent types. For example, map empty strings to null and omit them, and normalize multi-select values into arrays.

UI controlUI valueAPI value
Text input""omit filter
Numeric input"10"10 number
Multi-select["a","b"]["a","b"]
Checkboxfalseomit filter or false depending on semantics

This reduces “why does filtering break sometimes” issues and makes exports match the visible table.

# Virtualization with TanStack Virtual: Tradeoffs and Implementation#

Virtualization is often the difference between a table that feels instant and one that locks up the browser. Rendering 5,000 rows with 20 cells each can mean 100,000 cells in the DOM. Even if each cell is “simple”, that volume tends to cause input lag and scroll jank.

What virtualization gives you#

Virtualization renders only the visible rows plus a small buffer. If your viewport shows 30 rows and you overscan by 10, you might render about 50 rows instead of 5,000.

MetricWithout virtualizationWith virtualization
DOM rows renderedpageSize or morevisible rows plus overscan
Scroll performanceoften degrades after 1,000+ rowsstable in most cases
Implementation complexitylowmoderate
Accessibility complexitymoderatehigher
Copy-paste selectioneasycan be surprising

Core setup pattern#

A reliable approach is to render a table-like layout with a scroll container and an inner spacer, then absolutely position rows by virtual offsets.

TypeScript
const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 44,
  overscan: 8,
});

Keep estimateSize close to reality. A mismatch causes visible “jumping” while measuring.

Virtualization tradeoffs you must plan for#

  1. 1

    Dynamic row heights
    If rows wrap text or have expandable content, heights change. Measurement becomes more expensive and scroll position can jump. Use fixed row heights when possible.

  2. 2

    Sticky headers and pinned columns
    Sticky header is usually fine, but pinned columns need careful layering. Pinned cells should use position: sticky and correct z-index so they don’t flicker when scrolling.

  3. 3

    Keyboard navigation and screen readers
    Virtualized rows may not be in the DOM, which can break some accessibility expectations. If your product requires heavy keyboard navigation in tables, test early.

  4. 4

    Row selection consistency
    Selection state must be independent of rendered rows. Store selection by stable row IDs, not by row index.

⚠️ Warning: Virtualization does not fix slow cell render logic. If each cell runs expensive formatting or complex components, you’ll still get jank. Memoize heavy cell renderers and keep cells pure.

# Column Pinning and Wide Table UX#

Wide enterprise tables often fail on UX before they fail on performance. Column pinning is a high-value feature because it keeps key identifiers and actions visible.

Pinning strategy#

Pinning should be treated as UI preference, not query state. Save it per user in localStorage or your user profile.

Column typeTypical placementWhy
Row selectionleft pinnedkeeps bulk selection accessible
Primary identifierleft pinnedusers need context while scrolling
Status badgesleft or centerdepends on workflow
Actions menuright pinnedconsistent place for actions
Numeric metricsscrollableusers compare across columns

Practical z-index rules#

A common failure mode is pinned columns rendering under normal cells when scrolling horizontally. Use these simple rules:

  • header pinned cells should have the highest stacking context
  • body pinned cells should be above non-pinned cells
  • right-pinned actions should be above everything in the body

Keep CSS consistent across header and body so there is no “split” look.

# Performance Pitfalls and How to Avoid Them#

If you want the table to feel fast, watch for these patterns. Most teams hit at least one of them when scaling.

Pitfall 1: Unstable column definitions#

If columns are recreated on every render, TanStack Table does more work, and your cells rerender unnecessarily.

Fix: wrap columns in useMemo and keep dependencies minimal.

TypeScript
const columns = React.useMemo(() => [
  // column defs here
], []);

Pitfall 2: Expensive cell formatting on every rerender#

Formatting dates, currencies, and derived values inside cell renderers adds up. A table with 50 visible rows and 20 columns is 1,000 cells. If each cell does non-trivial work on every render, you’ll feel it.

Fix: precompute on the server when possible, or memoize derived values per row.

Pitfall 3: Coupling filter input state to query state#

If every keystroke updates query state, it can trigger refetches and rerenders. Even with React Query cancellation, you can overload the server and degrade perceived performance.

Fix: local input state plus debounced commit, as shown earlier.

Pitfall 4: “Infinite scroll” without clear position and totals#

Users need to know where they are. Without page context or total count, they can’t answer “how many results exist” or “did I reach the end”.

Fix: show total count and keep a “Back to top” affordance. For admin-style tools, pagination is often a better UX than infinite scroll.

# A Reusable Export Pattern That Matches Filters and Sorting#

Exports fail in production when they don’t match what the user sees. The export must reuse the same query state, and it must handle large datasets safely.

Decide between client export and server export#

Export typeWhen it worksLimits
Client-side CSVdata already loaded and smallmemory usage and slow stringify for large exports
Server-side CSV/XLSXlarge datasets or “export all results”requires background job and storage
Async job with email/linkvery large exportsmore infra, best UX for huge datasets

A practical threshold is: if export exceeds 5,000 to 20,000 rows, move it to server-side. Browser CSV generation can take seconds and may freeze the UI, especially on lower-end devices.

Pattern: export request uses the same query state#

Create a function that converts TableQueryState into an export payload. The key is consistency: the same filters and sorting as the table query.

TypeScript
type ExportFormat = "csv" | "xlsx";
 
function buildExportPayload(query: TableQueryState, format: ExportFormat) {
  return {
    format,
    query,
    createdAt: new Date().toISOString(),
  };
}

Server-export flow with polling#

  1. 1
    POST /api/exports with payload
  2. 2
    server returns jobId
  3. 3
    poll /api/exports/:jobId until status is ready
  4. 4
    download from downloadUrl
TypeScript
async function startExport(query: TableQueryState, format: "csv" | "xlsx") {
  const res = await fetch("/api/exports", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(buildExportPayload(query, format)),
  });
  if (!res.ok) throw new Error("Export start failed");
  return res.json() as Promise<{ jobId: string }>;
}

Keep polling simple and stop when ready:

TypeScript
async function waitForExport(jobId: string) {
  for (let i = 0; i < 60; i += 1) {
    const res = await fetch(`/api/exports/${jobId}`);
    const data = await res.json() as { status: "queued" | "running" | "ready"; downloadUrl?: string };
    if (data.status === "ready" && data.downloadUrl) return data.downloadUrl;
    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new Error("Export timed out");
}

This keeps UI responsive and avoids long-running requests that time out behind proxies.

ℹ️ Note: If you already use n8n, exports are a strong automation candidate. A workflow can generate files, store them, and email a download link, while your app only tracks job status.

UX details that prevent support tickets#

  • show a non-blocking progress state, not a modal that locks the UI
  • include export scope, such as “Exporting all filtered results”
  • include a time range or filter summary in the filename
  • log export jobs for auditing if the data is sensitive

# Putting It Together: A Scalable Table Checklist#

Use this as a pre-production checklist when shipping React data table patterns.

AreaDone looks likeCommon failure
Stateone serializable objectduplicated state across components
Server queryuses query key with query staterefetch loops due to unstable keys
Sortingmanual, server-sidemismatch between UI arrows and server sorting
Filtersdebounced commit + cancellationrequest spam and input lag
Virtualizationstable height, measured when neededjumping scroll due to wrong estimate
Pinningsticky + correct z-indexpinned columns flicker or overlap
Exportreuses the same query stateexport does not match visible rows

# Key Takeaways#

  • Centralize table state into one serializable object, and reset pageIndex to zero on sorting and filter changes.
  • Use server-side pagination, sorting, and filtering with stable React Query keys, and keep UI-only state out of query keys.
  • Implement debounced filters with local input state plus delayed commit to avoid request spam and lag.
  • Add virtualization when row counts or cell complexity cause DOM pressure, but plan for dynamic height, accessibility, and sticky pinning tradeoffs.
  • Treat exports as a first-class feature: reuse query state, prefer server-side jobs for large datasets, and provide clear UX feedback.

# Conclusion#

Scaling tables is less about one library and more about consistent patterns: a single state model, server-driven querying, debounced inputs, virtualization where it truly pays off, and exports that match exactly what users see. If you want help implementing these React data table patterns in a production React or Next.js app, Samioda can design the table architecture, performance budget, and export pipeline end-to-end. Reach out via Samioda and we’ll review your current table implementation and recommend concrete fixes.

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.