I am working on a retail POS platform with many tables. Canvassers, merchants, inventory, transactions, settlements, terminals, orders, returns, invoices, campaigns, wallets — by the time you count every list view across the admin and merchant dashboards, you're well past fifty distinct table screens. Each one needs sorting, filtering, and pagination against datasets that can run into the tens of thousands of rows.
We use TanStack Table v8 for all of them. But if you went looking through our codebase for getSortedRowModel, SortingState, or getFilteredRowModel, you wouldn't find them. We use maybe 10% of what the library offers — and that turned out to be the right call.
This post is about what we built, and more usefully, why we deliberately didn't reach for the rest of the API.
One component, not fifty
The first decision that mattered wasn't about TanStack Table at all — it was about not repeating ourselves. Every table in the app renders through a single generic component:
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
isLoading?: boolean;
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
notFoundText?: string;
};
function DataTable<TData, TValue>({ columns, data, ...pagination }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// render skeleton | empty state | table (desktop) | cards (mobile) + pagination
}
That's the entire useReactTable call — getCoreRowModel() and nothing else. Every feature page supplies columns and data, and gets header rendering, cell rendering, a loading skeleton, an empty state, and a fully responsive layout for free. On mobile, the same ColumnDef array that builds table headers/cells also builds a card layout — one card per row — so we're not maintaining a second UI for small screens.
The payoff: adding a table for a new entity is a matter of writing a column definition file and a data-fetching hook. Nobody re-implements pagination controls or loading spinners.
Column definitions: accessorKey for data, id + cell for everything else
Most columns are a direct passthrough:
const columns: ColumnDef<Merchant>[] = [
{
accessorKey: "businessName",
header: "Business Name",
},
{
accessorKey: "email",
header: "Email",
},
];
But a lot of our real columns aren't plain fields — they're a status badge, a formatted currency value, a name built from two fields, or a dropdown of row actions. For those we skip accessorKey/accessorFn entirely and use an id + custom cell, reading straight off row.original:
{
id: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: "",
cell: ({ row }) => <RowActionsMenu record={row.original} />,
},
For actions that need router access (navigate to a detail page, open a modal, trigger a mutation), the column array is built inside a small useXColumns() hook rather than defined as a static export, so it can close over router, queryClient, and any dialog state it needs.
The interesting part: we don't sort or filter with the table
This is the part that surprises people who've used TanStack Table before. There's no SortingState, no onSortingChange, no clickable column headers, no globalFilter. Every table's sort order, search term, status filter, and date range live in plain page-level state and get sent to the API:
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<string>();
const { data, isLoading } = useQuery({
queryKey: ["merchants", page, limit, search, status],
queryFn: () => fetchMerchants({ page, limit, search, status }),
placeholderData: keepPreviousData,
});
The "sort" control is just a <Select> whose value gets appended to the same query params. The API does the actual ordering, filtering, and slicing, and returns { data, count, totalPages }. DataTable just renders whatever page of rows it's handed.
Why not let TanStack Table's row models do this client-side? Because our datasets don't fit in a browser tab. A transactions table can span months of records; fetching everything and sorting/filtering in memory doesn't scale, and it also doesn't match how the rest of the system already thinks about data. Our APIs are already paginated and filterable, so pushing sort/filter/paginate to the server keeps one source of truth for "what does page 3 of this query look like" — instead of two (server pagination plus client row-model logic layered on top of it).
TanStack Query's keepPreviousData (now placeholderData: keepPreviousData in v5) is what makes this feel smooth — when the search term or page changes, the old rows stay on screen instead of flashing an empty/loading state while the new request resolves.
Pagination: server-driven, one shared control
Pagination follows the same philosophy. DataTable never touches getPaginationRowModel — it renders exactly the array it's given. Page number, page size, and total count all come from the API response and flow into a shared <CustomPagination /> component: page buttons with ellipsis for large ranges, a page-size selector, a "jump to page" input, and a results count. It's rendered twice inside DataTable — once for the desktop table view, once for the mobile card view — so both layouts stay in sync automatically.
Loading and empty states, baked in
Rather than every page hand-rolling a spinner, DataTable accepts isLoading and renders animated skeleton rows matching the current column count, then falls back to a configurable empty state (icon + message) when the row count is zero.
A couple of older pages predate this and swap in a standalone LoadingTable component at the call site instead — a good reminder that once you have a shared component, it's worth going back and migrating the stragglers rather than letting two patterns coexist indefinitely.
What we'd change
Nothing here is gospel. The most obvious gap: search inputs fire a new request on every keystroke, relying on keepPreviousData to hide the flicker. It works, but it's more network traffic than necessary — a useDebouncedValue around the search term before it hits the query key would cut request volume meaningfully with no UX cost. It's on our list.
We also don't do row selection, expandable rows, or virtualization anywhere yet. For our current page sizes (10–50 rows, server-paginated) that's fine; if a future table needs to show thousands of rows at once without pagination, that's when @tanstack/react-virtual earns its place — not before.
The actual lesson
TanStack Table is often introduced as a batteries-included package: sorting, filtering, pagination, selection, grouping, virtualization, all wired through row models. The useful realization for us wasn't learning that API surface — it was recognizing that our tables didn't need most of it. TanStack Table earns its keep here purely as a rendering layer: it turns a ColumnDef array into consistent headers and cells, and gets out of the way of everything else. Sorting, filtering, and pagination are business logic that already lived on our server, and duplicating that logic client-side would have been the actual mistake.
If you're reaching for TanStack Table on a data-heavy admin panel, the first question worth asking isn't "which row models do I need" — it's "which of these concerns does my backend already own."
For us, the answer was: most of them.
How do you handle tables in your admin panels — client-side row models or server-driven? I'd love to hear where you landed and why.
Top comments (0)