| name | react/client-to-server |
| description | Convert a client-side `@tanstack/react-table` v9 table to server-side (manual modes). Pass server-paginated/sorted/filtered rows as `data`, set `manualPagination` / `manualSorting` / `manualFiltering` / `manualGrouping` / `manualExpanding` for whatever the server now owns, supply `rowCount` so `getPageCount()` works, and DROP the matching `rowModels` entry (no `paginatedRowModel` if the server paginates). Own the relevant state slices via external atoms (`useCreateAtom` + `options.atoms`) so a query can key on the slice and refetch automatically — OR via classic `state` + `on*Change` controlled state.
|
| type | lifecycle |
| library | tanstack-table |
| framework | react |
| library_version | 9.0.0-alpha.48 |
| requires | ["state-management","pagination","filtering","sorting","react/table-state"] |
| sources | ["TanStack/table:examples/react/basic-external-atoms/src/main.tsx","TanStack/table:examples/react/with-tanstack-query/src/main.tsx","TanStack/table:examples/react/with-tanstack-query/src/fetchData.ts"] |
This skill builds on tanstack-table/state-management and tanstack-table/react/table-state. Read those first — the atom model is what makes the cleanest server-side wiring possible.
Why "client-to-server"
A client-side table sees every row, sorts/filters/paginates them locally, and renders a slice. A server-side table sees only the slice the server returned for the current request; the table must be told "don't try to slice this again — and here's the total row count so you can render a pager".
Four moves convert any client table to a server table:
manualX: true for whichever operations the server owns.
- Drop the matching factory from
rowModels so it doesn't ship in your bundle.
- Provide
rowCount so table.getPageCount() / getCanNextPage() work.
- Own the slice state externally so your data fetcher can key on it.
Setup
Two state-ownership patterns work; pick one per slice.
Pattern A — external atom (cleanest with Query/SWR)
import * as React from 'react'
import { useCreateAtom, useSelector } from '@tanstack/react-store'
import {
useTable,
tableFeatures,
rowPaginationFeature,
createColumnHelper,
} from '@tanstack/react-table'
import type { PaginationState } from '@tanstack/react-table'
const features = tableFeatures({ rowPaginationFeature })
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('firstName', { header: 'First' }),
columnHelper.accessor('age', { header: 'Age' }),
])
const EMPTY: Person[] = []
function ServerTable() {
const paginationAtom = useCreateAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const pagination = useSelector(paginationAtom)
const [serverPage, setServerPage] = React.useState<{
rows: Person[]
rowCount: number
} | null>(null)
React.useEffect(() => {
let cancelled = false
fetchPeople(pagination).then((page) => {
if (!cancelled) setServerPage(page)
})
return () => {
cancelled = true
}
}, [pagination])
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage?.rows ?? EMPTY,
rowCount: serverPage?.rowCount,
atoms: { pagination: paginationAtom },
manualPagination: true,
})
}
Source: examples/react/basic-external-atoms/src/main.tsx (atoms wiring); examples/react/with-tanstack-query/src/main.tsx (rowCount + manualPagination).
Pattern B — classic state + on*Change
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage?.rows ?? EMPTY,
rowCount: serverPage?.rowCount,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
})
Both work. state + on*Change is familiar from v8; atoms compose more cleanly with Query (the table writes to the atom, the query key includes the atom value, the query refetches automatically).
Core Patterns
Combining server-side sort + filter + pagination
Add the matching manual* flags for each operation the server now owns. Local features (column visibility, ordering, pinning) still work because they don't depend on the row model.
const features = tableFeatures({
rowSortingFeature,
rowPaginationFeature,
columnFilteringFeature,
columnVisibilityFeature,
columnPinningFeature,
})
const sortingAtom = useCreateAtom<SortingState>([])
const paginationAtom = useCreateAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const columnFiltersAtom = useCreateAtom<ColumnFiltersState>([])
const sorting = useSelector(sortingAtom)
const pagination = useSelector(paginationAtom)
const columnFilters = useSelector(columnFiltersAtom)
const serverArgs = { sorting, pagination, columnFilters }
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage?.rows ?? EMPTY,
rowCount: serverPage?.rowCount,
atoms: {
sorting: sortingAtom,
pagination: paginationAtom,
columnFilters: columnFiltersAtom,
},
manualSorting: true,
manualFiltering: true,
manualPagination: true,
})
Source: examples/react/basic-external-atoms/src/main.tsx.
When NOT to manual-mode a slice
If the server returns the entire dataset, leave the table client-side. Manual mode is for slices the server has already trimmed.
Common Mistakes
CRITICAL Forgetting manualPagination / manualSorting / manualFiltering
Wrong:
const table = useTable({
features,
rowModels: { paginatedRowModel: createPaginatedRowModel() },
columns,
data: serverPage.rows,
})
Correct:
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage.rows,
rowCount: serverPage.rowCount,
manualPagination: true,
})
Without manualPagination: true, the table tries to slice the already-server-sliced page a second time, producing rows that don't exist (or visibly wrong pagination).
Source: examples/react/with-tanstack-query/src/main.tsx.
CRITICAL Missing rowCount
Wrong:
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage.rows,
manualPagination: true,
})
Correct:
const table = useTable({
features,
rowModels: {},
columns,
data: serverPage.rows,
rowCount: serverPage.rowCount,
manualPagination: true,
})
Without rowCount, getPageCount() falls back to Math.ceil(data.length / pageSize) — which is 1 if the server returned a single page.
Source: examples/react/with-tanstack-query/src/main.tsx.
HIGH state.pagination without onPaginationChange
Wrong:
const [pagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
useTable({
features,
rowModels: {},
columns,
data,
state: { pagination },
manualPagination: true,
})
Correct:
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
useTable({
features,
rowModels: {},
columns,
data,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
})
The library treats state as controlled; without a writeback handler, table.setPageIndex(...) writes nowhere.
Source: docs/framework/react/guide/table-state.md.
HIGH Leaving paginatedRowModel registered for a server-paginated table
Wrong:
useTable({
features,
rowModels: { paginatedRowModel: createPaginatedRowModel() },
columns,
data: serverPage.rows,
manualPagination: true,
})
Correct:
useTable({
features,
rowModels: {},
columns,
data: serverPage.rows,
rowCount: serverPage.rowCount,
manualPagination: true,
})
The factory ships in your bundle for no reason. Manual mode + the factory will also let the factory re-slice your already-sliced server page if manualPagination is ever flipped off.
Source: maintainer guidance.
HIGH Mixing state.X and atoms.X for the same slice
Wrong:
useTable({
features,
rowModels: {},
columns,
data,
state: { pagination },
onPaginationChange: setPagination,
atoms: { pagination: paginationAtom },
manualPagination: true,
})
Correct:
useTable({
features,
rowModels: {},
columns,
data,
atoms: { pagination: paginationAtom },
manualPagination: true,
})
Precedence is options.atoms[key] > options.state[key] > internal. The state plumbing is dead code in this configuration.
Source: examples/react/basic-external-atoms/src/main.tsx.
MEDIUM Recreating data array identity in JSX
Wrong:
<MyTable data={query.data?.rows ?? []} columns={columns} />
Correct:
const EMPTY: Person[] = []
<MyTable data={query.data?.rows ?? EMPTY} columns={columns} />
Internal memoization keys off identity. A fresh [] each render bypasses memos and may force re-computation.
Source: maintainer guidance; examples/react/with-tanstack-query/src/main.tsx.
See Also
tanstack-table/react/compose-with-tanstack-query — the canonical Query + server-side pattern.
tanstack-table/react/compose-with-tanstack-store — sharing slice atoms across components.
tanstack-table/react/table-state — selectors, <Subscribe>, external atoms.
tanstack-table/react/production-readiness — when to narrow selectors as the table grows.