TanStack Table v8 Server-Side Data Grid — Delegating Pagination, Sorting, and Filtering to the Server with manualPagination·manualSorting·manualFiltering
When dealing with order lists or user tables with hundreds of thousands of records, you quickly realize that client-side sorting and filtering is practically useless. It's simply not feasible to fetch the entire dataset on initial load and sort it in memory. That's why most projects eventually migrate to an architecture where the server handles pagination, sorting, and filtering entirely. The challenge is that figuring out a clean pattern to wire this all together can be quite a struggle the first time around. It was for me, too.
TanStack Table v8 addresses this with three options: manualPagination, manualSorting, and manualFiltering. The core idea is to turn TanStack Table into a "display-only layer," while TanStack Query automatically triggers server re-fetches whenever state changes. In this article, we'll walk through how to connect all three options into a single cohesive flow — covering filter-change page auto-reset, URL state synchronization, and other real-world situations you'll inevitably encounter.
Core Concepts
Headless Data Grid and the "Display-Only Layer"
Headless Data Grid: A library pattern that provides only the behavioral logic of a table — sorting, pagination, filtering — without getting involved in UI rendering. Markup and styling are 100% under the developer's control.
TanStack Table v8 is the canonical implementation of this pattern. Its adapter-based architecture is not tied to any specific framework — React, Vue, Solid, Svelte — so once you learn the pattern, you can reuse it regardless of your stack.
By default, TanStack Table directly sorts and paginates the full dataset it receives on the client. However, the moment you set the following three options to true, the table instance gives up performing calculations and simply renders whatever data it is given.
| Option | Default | Role |
|---|---|---|
manualPagination |
false |
Disables client-side pagination model |
manualSorting |
false |
Disables client-side sorting model |
manualFiltering |
false |
Disables client-side filter model |
With all three set to true, "which page we're on, which column we're sorting by, and which filters are applied" are all managed as external state. TanStack Query is responsible for sending a re-fetch request to the server whenever that state changes.
The Controlled State Pattern — Managing State Outside the Table
The key to a server-side grid is the Controlled State pattern. You inject external state created with React's useState into the table, and when state changes within the table (onPaginationChange, onSortingChange, onColumnFiltersChange), you update the external state back.
By including these states in TanStack Query's queryKey, a server re-fetch is automatically triggered whenever the state changes. Since TanStack Query re-executes queryFn whenever queryKey changes, as long as you wire up the state correctly, data refreshes naturally without needing a separate useEffect or event handler.
Here's the flow in pseudocode:
[User Interaction]
→ Column header click (sort) / filter input / page navigation
→ onSortingChange / onColumnFiltersChange / onPaginationChange called
→ External state updated
→ queryKey change detected
→ TanStack Query re-executes queryFn
→ New data received from server
→ Table re-renderscolumnFilters vs globalFilter — Which Should You Use?
This is a common point of confusion when first dealing with column filtering. TanStack Table has two filter states:
columnFilters: Specifies filter conditions per individual column. Structure is an array of{ id: string; value: unknown }.globalFilter: A single search value targeting all columns. Used for a unified search bar UI.
This article covers column-by-column filtering based on columnFilters. If you need a unified search bar that searches across any column — name, email, status — connect globalFilter and onGlobalFilterChange separately.
One thing worth noting: when you set manualFiltering: true, TanStack Table's client-side filterFns do not execute. filterFns only operate in client filtering mode when manualFiltering: false; in server-side mode, no filterFn you specify will run. I missed this at first and spent a long time wondering why my custom filterFn wasn't working after writing it carefully.
Practical Application
Example 1: Basic Structure for Connecting Filters, Sorting, and Pagination to the Server
Let's look at a complete useReactTable configuration along with the JSX rendering. You can see the entire flow at once — from a header click becoming a sort request, to a state change triggering a server re-fetch.
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
useReactTable,
getCoreRowModel,
flexRender,
type SortingState,
type ColumnFiltersState,
type PaginationState,
} from '@tanstack/react-table';
export function UserTable() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// All three states included in queryKey → any change triggers automatic re-fetch
const { data, isLoading } = useQuery({
queryKey: ['users', pagination, sorting, columnFilters],
queryFn: () => fetchUsers({ pagination, sorting, columnFilters }),
placeholderData: keepPreviousData,
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableMultiSort: false, // Recommended to declare explicitly if multi-sort is unsupported
rowCount: data?.totalCount, // v8.13.0+: pageCount calculated automatically
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: (updater) => {
setSorting(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
onColumnFiltersChange: (updater) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
});
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: header.column.getCanSort() ? 'pointer' : 'default' }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' ? ' ↑' : ''}
{header.column.getIsSorted() === 'desc' ? ' ↓' : ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={columns.length}>Loading...</td></tr>
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}The critical piece in the header is onClick={header.column.getToggleSortingHandler()}. When this handler is called, onSortingChange fires, setSorting updates the state, queryKey changes, and a server re-fetch automatically follows. The entire flow from a single UI click to a server request is completed here.
The sorting array can hold multiple sort conditions. If you don't support multi-sort, it's recommended to explicitly declare enableMultiSort: false. When transforming for API calls, you can handle it like this:
// Single sort parameter conversion (when enableMultiSort: false)
// Generic format: ?sortBy=name&sortOrder=asc
// Spring Boot Pageable format: ?sort=name,asc (adjust to match your backend style)
const sortBy = sorting[0]?.id;
const sortOrder = sorting[0]?.desc ? 'desc' : 'asc';
// Convert columnFilters array → API parameter object
const filterParams = columnFilters.reduce((acc, filter) => {
// Note: if filter.value is an array ([Date, Date] for range filters, etc.)
// passing it directly to URLSearchParams will serialize it as '[object Object]'
// Compound values require JSON.stringify or custom serialization
acc[filter.id] = filter.value as string;
return acc;
}, {} as Record<string, string>);
const fetchUsers = async ({
pagination,
sorting,
columnFilters,
}: {
pagination: PaginationState;
sorting: SortingState;
columnFilters: ColumnFiltersState;
}) => {
const params = new URLSearchParams({
page: String(pagination.pageIndex),
size: String(pagination.pageSize),
...(sortBy && { sortBy, sortOrder }),
...filterParams,
});
const res = await fetch(`/api/users?${params}`);
return res.json() as Promise<{ rows: User[]; totalCount: number }>;
};If you're maintaining a project on a version prior to v8.13.0, you'll need to manually calculate pageCount and pass it in (Math.ceil(total / pageSize)) instead of using rowCount. From v8.13.0 onwards, passing rowCount alone lets the table automatically derive pageCount based on the current pageSize. Mixing both causes conflicts, so check your version first and use only one.
| Target | Page Reset Required | Reason |
|---|---|---|
onColumnFiltersChange |
Required | When filter conditions change, the result count changes and the current page may no longer exist |
onSortingChange |
Recommended | Technically fine, but reset is recommended for UX consistency |
onPaginationChange |
Not needed | The purpose is page navigation, so no reset needed |
Honestly, this reset handling is the most commonly overlooked part. There is an autoResetPageIndex option, but there are bug reports (#5611, #4797) that it doesn't behave as expected when manualPagination: true, so handling it explicitly as shown above is the safer approach.
Example 2: Debouncing Text Filters to Prevent API Over-Calling
Without debouncing on text input filters, a server request fires on every keystroke. Searching for "John Smith" would generate 10 API calls. This is a situation you'll frequently encounter in practice. Instead of using the columnFilters state directly, you can solve this by passing a debounced version to queryKey.
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// Inside the component
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// UI responds instantly, but server requests only fire after 300ms
const debouncedFilters = useDebounce(columnFilters, 300);
const { data } = useQuery({
queryKey: ['users', pagination, sorting, debouncedFilters],
queryFn: () => fetchUsers({
pagination,
sorting,
columnFilters: debouncedFilters, // Only the debounced value goes to the server
}),
placeholderData: keepPreviousData, // Keep previous data during re-fetch
});
const table = useReactTable({
data: data?.rows ?? [],
state: {
pagination,
sorting,
columnFilters, // UI state reflects immediately
},
onColumnFiltersChange: (updater) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
},
// ...remaining options
});placeholderData: keepPreviousData is an option that keeps displaying previous data while new data is being fetched. It prevents the table from flickering to a blank screen every time you change a filter — once you use it, you won't be able to live without it.
Example 3: URL State Synchronization — Making Filters, Sorting, and Pages Bookmarkable
When discussing a grid with teammates, it's inevitable that someone will say, "Wouldn't it be great if we could share this filter state via URL?" The tanstack-table-search-params library lets you avoid writing the serialization logic yourself.
import { useTableSearchParams } from 'tanstack-table-search-params';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
export function UserTable() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Gets state + handlers that are bidirectionally synced with URL parameters all at once
const stateAndOnChanges = useTableSearchParams({
push: (url) => router.push(url),
pathname,
searchParams,
});
const { data } = useQuery({
queryKey: ['users', stateAndOnChanges.state],
queryFn: () => fetchUsers(stateAndOnChanges.state),
placeholderData: keepPreviousData,
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
rowCount: data?.totalCount,
...stateAndOnChanges, // Injects state + onPaginationChange + onSortingChange + onColumnFiltersChange
});
// Rendering is the same as Example 1
}With this approach, URLs are automatically managed in the form https://example.com/users?page=2&sortBy=name&sortOrder=asc&status=active. Share the URL with a teammate and the same filter and sort state is restored.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Full UI control | Markup and styling are 100% under your control. Integration with design systems is natural |
| Lightweight bundle | Core is approximately 10–20KB. Unused features are not included in the bundle |
| Framework-agnostic | The same API works across React, Vue, Solid, Svelte, and vanilla JS |
| MIT License | No licensing costs like AG Grid Enterprise |
| Type safety | TypeScript-based with type inference from column definitions down to filter values |
| TanStack ecosystem synergy | Integrates naturally with TanStack Query and TanStack Router |
Disadvantages and Caveats
The initial implementation cost can be significantly reduced with the Shadcn UI DataTable boilerplate, but what still trips people up is expressing compound filters. The moment you need AND/OR conditions, the basic structure hits its limits.
| Item | Details | Mitigation |
|---|---|---|
| Initial implementation cost | Filter UI, cell editors, etc. must be built from scratch | Use the Shadcn UI DataTable pattern as a boilerplate |
| No built-in virtualization | DOM performance issues may occur when rendering tens of thousands of rows | @tanstack/react-virtual must be composed separately |
autoResetPageIndex bug |
Doesn't behave as expected when manualPagination: true |
Explicitly reset pageIndex to 0 in onColumnFiltersChange |
| Filter serialization overhead | Logic needed to convert the columnFilters array into API parameters |
Recommend using tanstack-table-search-params or nuqs |
| Compound filter expression limits | Difficult to express AND/OR conditions with basic ColumnFiltersState |
Design a custom filter state separately |
Virtualization: A technique that renders only the rows currently visible on screen instead of rendering all rows in the DOM. In server-side mode, you're already fetching only a small number of rows per page, so it's usually unnecessary — but it's worth considering if you're displaying hundreds to thousands of rows on a single page.
The Most Common Mistakes in Practice
- Not resetting the page when filters change — The most frequently occurring bug. If you don't explicitly reset
pageIndexto 0 when filters and sorting change, the server receives a request for a page that doesn't exist and the screen may go blank. - Not applying debouncing to text filters — The API gets called on every single keystroke. A 300ms debounce is practically a mandatory option.
- Passing
filter.valuearrays directly to URLSearchParams — Whenfilter.valueis in the form[Date, Date](like a date range filter), passing it directly toURLSearchParamsserializes it as[object Object]. Compound value filters require separate serialization handling. - Mixing
rowCountandpageCount— Passing both by mixing the pre-v8.13.0 approach with the newer one causes conflicts. Check your version first and use only one.
Closing Thoughts
The biggest change you'll notice when introducing this pattern to a team is that the question "Is it a table bug or a server issue?" comes up far less often. Because TanStack Table is responsible only for display and data processing responsibility is clearly delegated to the server, the debugging scope narrows considerably when something goes wrong. TanStack Table v8's three manual* options are not merely configuration flags — they are an architectural decision that declares the boundary between client and server responsibilities.
Three steps you can start with right now:
- Install packages with
pnpm add @tanstack/react-table @tanstack/react-query, then try adding justmanualPagination: trueto your existing client-side table. It's the easiest first step to experience how pagination gets handed off to the server. - Add
manualFiltering: trueand connectonColumnFiltersChangetogether with asetPaginationreset. Try attaching an<input>to a column header and verify firsthand that the filter value gets converted into an API parameter. - Install
pnpm add tanstack-table-search-paramsand connect theuseTableSearchParamshook, and filter, sort, and page state will automatically be reflected in the URL. You'll be able to share debugging context with teammates just by sending them a URL.
References
- TanStack Table v8 — Pagination Guide | Official
- TanStack Table v8 — Column Filtering Guide | Official
- TanStack Table v8 — Sorting Guide | Official
- TanStack Table v8 — Table State (React) Guide | Official
- TanStack Table v8 — Pagination APIs | Official
- TanStack Table v8 — Column Filtering APIs | Official
- React TanStack Table Query Router Search Params Example | Official
- Server Side Pagination, Column Filtering and Sorting With TanStack Table and Query Library | Medium
- Server-side Pagination and Sorting with TanStack Table and React | Medium
- Server-Side Table Operations Made Simple: React + TanStack + Spring Boot | Medium
- TanStack Table vs AG Grid: Complete Comparison 2025 | Simple Table
- tanstack-table-search-params — URL parameter sync library | GitHub
- Building a Library to Sync TanStack Table State with URL Parameters | DEV.to
- GitHub Issue #4797 — Manual Pagination doesn't reset PageIndex when Manual Column Filtering
- GitHub Issue #5611 — autoResetPageIndex behavior issue
- TanStack Table v8 Complete Semantic Data Grid Demo | DEV.to