# 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#
| Requirement | Version | Notes |
|---|---|---|
| React | 18+ | Concurrent rendering helps with perceived responsiveness |
| TanStack Table | 8+ | Guide assumes v8 patterns and APIs |
| TanStack Virtual | 3+ | For row virtualization |
| TanStack Query | 5+ | For server state, caching, and cancellation |
| TypeScript | Recommended | Tables 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 slice | Example fields | Used for |
|---|---|---|
| Pagination | pageIndex, pageSize | Server query offset and UI |
| Sorting | id, desc | Server sorting and UI indicators |
| Column filters | columnId, value | Server filtering, filter UI |
| Global search | query | Server query, search input |
| Column pinning | left, right arrays | UX for wide tables |
| Column visibility | hidden set | Personalization |
| Row selection | selectedRowIds | Bulk 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#
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.
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
pageIndexto 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.
| Parameter | Example | Notes |
|---|---|---|
page | 0 | Zero-based page index is easiest with TanStack |
pageSize | 50 | Keep a reasonable max, e.g. 200 |
sort | createdAt:desc | Support multiple sorts if needed |
filters | status:active | Prefer JSON encoding for complex filters |
q | john | Global 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.
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
queryplain 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 option | Recommended value | Why |
|---|---|---|
manualPagination | true | Server decides pages |
manualSorting | true | Server decides sorting |
manualFiltering | true | Server applies filters |
pageCount or rowCount | from API total | Enables 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.
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:
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 control | UI value | API value |
|---|---|---|
| Text input | "" | omit filter |
| Numeric input | "10" | 10 number |
| Multi-select | ["a","b"] | ["a","b"] |
| Checkbox | false | omit 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.
| Metric | Without virtualization | With virtualization |
|---|---|---|
| DOM rows rendered | pageSize or more | visible rows plus overscan |
| Scroll performance | often degrades after 1,000+ rows | stable in most cases |
| Implementation complexity | low | moderate |
| Accessibility complexity | moderate | higher |
| Copy-paste selection | easy | can 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.
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
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
Sticky headers and pinned columns
Sticky header is usually fine, but pinned columns need careful layering. Pinned cells should useposition: stickyand correctz-indexso they don’t flicker when scrolling. - 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
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 type | Typical placement | Why |
|---|---|---|
| Row selection | left pinned | keeps bulk selection accessible |
| Primary identifier | left pinned | users need context while scrolling |
| Status badges | left or center | depends on workflow |
| Actions menu | right pinned | consistent place for actions |
| Numeric metrics | scrollable | users 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.
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 type | When it works | Limits |
|---|---|---|
| Client-side CSV | data already loaded and small | memory usage and slow stringify for large exports |
| Server-side CSV/XLSX | large datasets or “export all results” | requires background job and storage |
| Async job with email/link | very large exports | more 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.
type ExportFormat = "csv" | "xlsx";
function buildExportPayload(query: TableQueryState, format: ExportFormat) {
return {
format,
query,
createdAt: new Date().toISOString(),
};
}Server-export flow with polling#
- 1POST
/api/exportswith payload - 2server returns
jobId - 3poll
/api/exports/:jobIduntil status isready - 4download from
downloadUrl
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:
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.
| Area | Done looks like | Common failure |
|---|---|---|
| State | one serializable object | duplicated state across components |
| Server query | uses query key with query state | refetch loops due to unstable keys |
| Sorting | manual, server-side | mismatch between UI arrows and server sorting |
| Filters | debounced commit + cancellation | request spam and input lag |
| Virtualization | stable height, measured when needed | jumping scroll due to wrong estimate |
| Pinning | sticky + correct z-index | pinned columns flicker or overlap |
| Export | reuses the same query state | export does not match visible rows |
# Key Takeaways#
- Centralize table state into one serializable object, and reset
pageIndexto 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
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 →React Component Contract Testing: MSW + Storybook as Living API Mocks
A practical guide to React component contract testing using shared MSW handlers across Storybook and automated tests to prevent mock drift, flaky UI, and regressions in CI.
Next.js B2B SaaS Admin Panel Architecture: RBAC, Audit Logs, Impersonation, and Safe Bulk Actions
A practical reference architecture for Next.js admin panels: permission modeling, audit logs, secure impersonation, and safety checklists for bulk actions, exports, and PII handling.
Building a Multi‑Step Wizard in Next.js App Router with Server Actions + Zod (No Extra API Layer)
Implement a production-grade Next.js multi step form using App Router Server Actions and Zod — with three state strategies (cookies, DB drafts, URL), accessible UX, optimistic transitions, and robust error handling without adding an API layer.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Table Virtualization & Infinite Scroll: Building Fast Data Grids with TanStack (2026 Guide)
Learn React table virtualization with TanStack Table, TanStack Virtual, and React Query: efficient rendering, infinite scroll, server-side sorting/filtering, URL sync, selection persistence, and optimistic updates.
React Virtualization Guide: Windowing Large Lists and Grids with TanStack Virtual (and When Not To)
A practical 2026 guide to React virtualization with TanStack Virtual: build fast lists and grids, add infinite loading and sticky headers, profile measurable gains, and avoid common edge cases like dynamic row heights and accessibility pitfalls.
Next.js App Router UX Patterns: Error Boundaries, Loading UI, and Streaming Done Right
A practical guide to resilient UX in Next.js App Router: route segment structure, error.tsx, loading.tsx, not-found.tsx, and Suspense streaming patterns for partial rendering, safer data fetching, and fewer layout shifts.