TanStack Table is a headless React table library: it handles sorting, filtering, pagination and selection while you write the markup. In version 9 you register each feature with tableFeatures, add a row model per client-side step, call useTable, and render headers and cells with FlexRender. The examples below are type-checked against 9.3.
| Rows | Approach |
|---|---|
| Under about 50, no interaction | A plain HTML table |
| Hundreds to a few thousand | TanStack Table, client side |
| Tens of thousands or more | TanStack Table with manual (server) sorting and paging |
| Very long single pages | Add row virtualisation |
Updated
Try the finished table, then copy the code that builds it. The live preview on this page is drawn with plain React state so the page stays light; the code beside it is the TanStack v9 version that does the same thing in your app.
Want a finished, styled version to copy? See our Tailwind React data table component.
One package. It has no styles and renders nothing by itself: you write the table markup, which is why it fits Tailwind so well.
npm install @tanstack/react-tableFeatures are listed up front and passed in once. Keep features, columns and the data array stable (outside the component or in state), or the table rebuilds its rows on every render.
import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table";
type Person = { name: string; city: string };
const features = tableFeatures({});
const helper = createColumnHelper<typeof features, Person>();
const columns = helper.columns([
helper.accessor("name", { header: "Name" }),
helper.accessor("city", { header: "City" }),
]);
const data: Person[] = [{ name: "Ada", city: "Leeds" }, { name: "Lin", city: "Perth" }];
export function People() {
const table = useTable({ features, columns, data });
return (
<table>
<thead>
{table.getHeaderGroups().map((g) => (
<tr key={g.id}>{g.headers.map((h) => <th key={h.id}><table.FlexRender header={h} /></th>)}</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (
<tr key={r.id}>{r.getAllCells().map((c) => <td key={c.id}><table.FlexRender cell={c} /></td>)}</tr>
))}
</tbody>
</table>
);
}| You want | Feature | Row-model slot (client side) |
|---|---|---|
| Click a header to sort | rowSortingFeature | sortedRowModel: createSortedRowModel() |
| A search box over all columns | columnFilteringFeature + globalFilteringFeature | filteredRowModel: createFilteredRowModel() |
| Filters per column | columnFilteringFeature | filteredRowModel: createFilteredRowModel() |
| Pages of rows | rowPaginationFeature | paginatedRowModel: createPaginatedRowModel() |
| Checkbox selection | rowSelectionFeature | None |
| Show and hide columns | columnVisibilityFeature | None |
| Server does the work | The same features | No slots; set manualSorting, manualFiltering, manualPagination |
Everything in one component, styled with Tailwind classes. The header row stays in place because the wrapper scrolls and the thead is sticky. aria-sort tells screen readers which way a column is sorted.
import {
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from "@tanstack/react-table";
type Order = { id: string; customer: string; status: "Paid" | "Pending" | "Refunded"; total: number };
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString },
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature,
columnVisibilityFeature,
});
const helper = createColumnHelper<typeof features, Order>();
const columns = helper.columns([
helper.display({
id: "select",
header: ({ table }) => (
<input type="checkbox" aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()} />
),
cell: ({ row }) => (
<input type="checkbox" aria-label={`Select order ${row.original.id}`}
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()} />
),
enableSorting: false,
enableHiding: false,
}),
helper.accessor("id", { header: "Order" }),
helper.accessor("customer", { header: "Customer", sortFn: "alphanumeric" }),
helper.accessor("status", { header: "Status" }),
helper.accessor("total", {
header: "Total",
cell: (info) => `$${info.getValue().toFixed(2)}`,
sortFn: "basic",
}),
]);
export function OrdersTable({ data }: { data: Order[] }) {
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
globalFilterFn: "includesString",
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
});
return (
<div>
<div className="flex flex-wrap gap-3 pb-3">
<input
value={String(table.state.globalFilter ?? "")}
onChange={(e) => table.setGlobalFilter(e.target.value)}
placeholder="Search orders"
aria-label="Search orders"
className="rounded-lg border px-3 py-1.5"
/>
{table.getAllLeafColumns().filter((c) => c.getCanHide()).map((c) => (
<label key={c.id} className="text-sm">
<input type="checkbox" checked={c.getIsVisible()} onChange={c.getToggleVisibilityHandler()} /> {c.id}
</label>
))}
</div>
<div className="max-h-96 overflow-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50">
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => {
const sorted = header.column.getIsSorted();
return (
<th key={header.id} className="px-3 py-2 text-left"
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}>
{header.column.getCanSort() ? (
<button onClick={header.column.getToggleSortingHandler()}>
<table.FlexRender header={header} />
{sorted === "asc" ? " ▲" : sorted === "desc" ? " ▼" : ""}
</button>
) : (
<table.FlexRender header={header} />
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={row.getIsSelected() ? "bg-blue-50" : ""}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="border-t px-3 py-2">
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-2 pt-3 text-sm">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</button>
<span>Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
<span>{Object.keys(table.state.rowSelection).length} selected</span>
</div>
</div>
);
}
For large datasets the server sorts and returns one page. Leave out the row-model slots, set the manual flags, and pass rowCount so the table knows how many pages exist. The sort and page state drive the request.
import { useEffect, useState } from "react";
import {
createColumnHelper, rowPaginationFeature, rowSortingFeature, tableFeatures, useTable,
type PaginationState, type SortingState,
} from "@tanstack/react-table";
type Order = { id: string; customer: string; total: number };
// No row-model slots: the server sorts and slices, the table only displays.
const features = tableFeatures({ rowSortingFeature, rowPaginationFeature });
const helper = createColumnHelper<typeof features, Order>();
const columns = helper.columns([
helper.accessor("customer", { header: "Customer" }),
helper.accessor("total", { header: "Total" }),
]);
const EMPTY: Order[] = [];
export function ServerOrders() {
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
const [page, setPage] = useState<{ rows: Order[]; total: number } | null>(null);
useEffect(() => {
const ctrl = new AbortController();
const sort = sorting[0] ? `${sorting[0].id}.${sorting[0].desc ? "desc" : "asc"}` : "";
const qs = new URLSearchParams({ page: String(pagination.pageIndex), size: String(pagination.pageSize), sort });
fetch(`/api/orders?${qs}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setPage)
.catch((e) => { if (e.name !== "AbortError") console.error(e); });
return () => ctrl.abort();
}, [sorting, pagination]);
const table = useTable({
features,
columns,
data: page?.rows ?? EMPTY,
manualSorting: true,
manualPagination: true,
rowCount: page?.total ?? 0,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
});
return (
<p>
Page {pagination.pageIndex + 1} of {table.getPageCount()}
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
</p>
);
}
| v8 | v9 |
|---|---|
| useReactTable({ getCoreRowModel: getCoreRowModel(), ... }) | useTable({ features, ... }); the core row model is automatic |
| getSortedRowModel: getSortedRowModel() | rowSortingFeature plus sortedRowModel: createSortedRowModel() in tableFeatures |
| table.getState().sorting | table.state.sorting |
| flexRender(cell.column.columnDef.cell, cell.getContext()) | <table.FlexRender cell={cell} /> (flexRender still works) |
| Everything bundled by default | Only the features you register are bundled |
If you would rather start from a finished design, our component gallery has a Tailwind data table with sorting, search, row selection and pagination that you can copy as React or HTML. For shadcn/ui projects, see our shadcn form and table guide.
Declaring columns inside the component without memoising them, which rebuilds the table every render.
Turning on manualPagination but still passing every row, so the first page shows everything.
Reversing the order inside a custom sort function. Return an ascending comparison; the library flips it for descending.
Yes. react-table v7 was renamed TanStack Table from v8 and published as @tanstack/react-table. Version 9 keeps the headless idea but asks you to register features explicitly, which keeps bundles smaller.
No. It gives you state and row models only. You write the table, th and td elements and style them with Tailwind, CSS or a component library such as shadcn/ui.
When the full dataset is too big to send to the browser, yes. Set the manual options, send the sort and page state to your API, and pass the total row count so page controls work.
Give the wrapper a max height and overflow auto, and make the thead sticky with top 0 and a background colour so rows do not show through.
Related: Tailwind React data table component, HTML table generator, React memo, useMemo and useCallback, JSON to HTML table, All free tools.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.