TL;DR

A TanStack Table v9 data table starts with tableFeatures: you register sorting, filtering, pagination, pinning and selection once at module scope and pass them to useTable, which replaces useReactTable. For server pagination you set manualPagination, manualSorting and manualFiltering with a rowCount from your API, and column pinning now uses start and end instead of left and right. The library gives you state and row models only; the filter UI, the bulk bar and a phone layout are still yours to build or install.

TanStack Table v9 went stable in August 2026, and many data table tutorials still show the v8 API: useReactTable, getCoreRowModel() and columnPinning.left. Copy one into a v9 project and the import fails, because useReactTable is gone; switch to useTable and nothing sorts until you register the sorting feature. This tutorial builds an admin data table on TanStack Table v9: column filters, bulk actions on selected rows, pinned columns and server pagination, plus the card layout it should switch to on a phone. The first half is plain TanStack, so it works with any markup. The second half uses the Wingo UI Data Table, which we build, so weigh our opinions accordingly.

What changed in TanStack Table v9 for a data table?

Features became explicit. You declare sorting, filtering, pagination, pinning and selection in a tableFeatures() object, and the row model factories live in that object instead of in the table options. The renames that touch a data table, from the React migration guide as of October 2026:

v8 v9
useReactTable(options) useTable(options, selector?) with a required features option
getCoreRowModel: getCoreRowModel() automatic
getSortedRowModel: getSortedRowModel() sortedRowModel: createSortedRowModel() inside tableFeatures
columnPinning: { left, right }, column.pin("left") columnPinning: { start, end }, column.pin("start")
sortingFn, sortingFns sortFn, sortFns
table.getState() table.state, or table.Subscribe for one slice
createColumnHelper<Person>() createColumnHelper<typeof features, Person>()

Three behavior changes bite during a migration. In 9.2.4, row.getVisibleCells() comes from columnVisibilityFeature, so a table without it has only getAllCells(), which ignores pinning order. getIsSomeRowsSelected() returns true when every row is selected, so an indeterminate header checkbox needs getIsSomeRowsSelected() && !getIsAllRowsSelected(). And row.getToggleSelectedHandler() selects a range on Shift-click by default (enableRowRangeSelection: false turns it off).

The v9 announcement (August 4, 2026) says the whole package grew from 14kb to 25kb, and that features you do not register stay out of the bundle. Register what the table uses and nothing else.

How do you set up TanStack Table v9 with filters, selection and pinning?

Build the features object and the columns once at module scope, then call useTable with the data. TanStack's docs ask for stable features, columns and data, and a fresh data array on every render invalidates every row model built from it. Here is a complete TanStack Table v9 example for a clients list:

// clients-grid.tsx "use client"; import type { CSSProperties } from "react"; import { columnFilteringFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature, createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_basic, tableFeatures, useTable, type Column, } from "@tanstack/react-table"; export type Client = { id: string; name: string; city: string; status: string; balance: number }; const features = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel(), sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic }, columnFilteringFeature, filteredRowModel: createFilteredRowModel(), filterFns: { includesString: filterFn_includesString, arrIncludesSome: filterFn_arrIncludesSome }, rowPaginationFeature, paginatedRowModel: createPaginatedRowModel(), columnSizingFeature, columnPinningFeature, columnVisibilityFeature, rowSelectionFeature, }); const helper = createColumnHelper<typeof features, Client>(); const columns = helper.columns([ helper.display({ id: "select", size: 40, header: ({ table }) => ( <input type="checkbox" aria-label="Select page" checked={table.getIsAllPageRowsSelected()} // v9: "some" stays true when all are selected ref={(el) => { if (el) el.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(); }} onChange={table.getToggleAllPageRowsSelectedHandler()} /> ), cell: ({ row }) => ( <input type="checkbox" aria-label="Select row" checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} /> ), }), helper.accessor("name", { header: "Client", size: 220, filterFn: "includesString", sortFn: "alphanumeric" }), helper.accessor("city", { header: "City", filterFn: "arrIncludesSome" }), helper.accessor("status", { header: "Status", filterFn: "arrIncludesSome" }), helper.accessor("balance", { header: "Balance", sortFn: "basic" }), ]); // fixed widths keep the sticky offsets true; pinned cells need a background function cellStyle(column: Column<typeof features, Client, unknown>): CSSProperties { const width = column.getSize(); const side = column.getIsPinned(); if (side === "start") return { width, position: "sticky", insetInlineStart: column.getStart("start"), zIndex: 1, background: "Canvas" }; if (side === "end") return { width, position: "sticky", insetInlineEnd: column.getAfter("end"), zIndex: 1, background: "Canvas" }; return { width }; } export function ClientsGrid({ data }: { data: Client[] }) { const table = useTable({ features, columns, data, getRowId: (row) => row.id, initialState: { columnPinning: { start: ["select", "name"], end: [] } }, }); return ( <div style={{ overflowX: "auto" }}> <table style={{ tableLayout: "fixed", width: table.getTotalSize() }}> <thead> {table.getHeaderGroups().map((group) => ( <tr key={group.id}> {group.headers.map((header) => ( <th key={header.id} style={cellStyle(header.column)}> {header.column.getCanSort() ? ( <button type="button" onClick={header.column.getToggleSortingHandler()}> <table.FlexRender header={header} /> </button> ) : ( <table.FlexRender header={header} /> )} </th> ))} </tr> ))} </thead> <tbody> {table.getRowModel().rows.map((row) => ( <tr key={row.id}> {row.getVisibleCells().map((cell) => ( <td key={cell.id} style={cellStyle(cell.column)}> <table.FlexRender cell={cell} /> </td> ))} </tr> ))} </tbody> </table> </div> ); } 

The fixed layout and the widths matter. getStart("start") returns 40 for the name column, the select column's size, so the browser has to draw that column 40px wide, or the pinned name column sticks with a gap in front of it. Canvas is the page background in the active color scheme; swap in your own token.

The filter functions are registered by name, so filterFn: "arrIncludesSome" is type-checked against the registry. Filtering and bulk work go through the same instance:

table.getColumn("status")?.setFilterValue(["Overdue"]); const ids = table.getSelectedRowModel().rows.map((row) => row.original.id); 

That is the whole job of the library: state, row models and handlers. Everything a person sees is still missing: a search box, filter menus with checkboxes and ranges, chips for the applied filters, a bar that swaps in when rows are selected, keyboard navigation between rows and a layout for 390px screens. If you started from the shadcn data table guide, it is written for TanStack Table v9 as of October 2026 and adds sorting, a text filter, column visibility, client pagination, a row actions menu and a selected-row count. Server pagination, pinning, bulk actions and a phone layout are not in it.

How do you get filters, bulk actions and pinning without building the UI?

Install a component that builds the same kind of TanStack table and renders those parts. Our Data Table runs on TanStack Table v9 with its features registered once at module scope, and takes plain column objects:

npx wingo-ui@latest login # once, with a Pro account npx wingo-ui@latest add data-table 
// clients-table.tsx "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Archive, Download, Pencil, Plus, Trash2 } from "lucide-react"; import { DataTable, createColumns } from "@/components/ui/data-table"; // your server actions; each takes the selected ids import { archiveClients, deleteClients, exportClients } from "./actions"; export type Client = { id: string; name: string; taxId: string; city: string; status: "Active" | "Overdue" | "Prospect"; owner: string; balance: number; lastInvoice: string | null; }; const col = createColumns<Client>(); export const columns = col([ col.accessor("name", { header: "Client", filter: "text", pinned: "left", mobile: "title" }), col.accessor("city", { header: "City", filter: "multi-select", mobile: "subtitle" }), col.accessor("status", { header: "Status", type: "badge", badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } }, filter: "multi-select", mobile: "meta", }), col.accessor("owner", { header: "Owner", type: "avatar", filter: "select" }), col.accessor("balance", { header: "Balance", type: "currency", footer: "sum", filter: "number-range" }), col.accessor("lastInvoice", { header: "Last invoice", type: "relative", filter: "date-range" }), // hidden by default, still found by the search col.accessor("taxId", { header: "Tax ID", searchable: true, mobile: "hidden" }), ]); export function ClientsTable({ clients }: { clients: Client[] }) { const router = useRouter(); const [selected, setSelected] = useState<string[]>([]); return ( <DataTable data={clients} columns={columns} caption="Clients" locale="en-US" searchPlaceholder="Search by name, tax ID or city" defaultColumnVisibility={{ taxId: false }} quickFilters={[ { id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } }, { id: "big", label: "Over $10k", filter: (row) => row.balance > 10_000 }, ]} selectionMode="multiple" selectedKeys={selected} onSelectedKeysChange={setSelected} bulkActions={(_rows, keys) => [ { label: "Export", icon: <Download />, onSelect: () => exportClients(keys) }, { label: "Archive", icon: <Archive />, onSelect: () => archiveClients(keys).then(() => setSelected([])) }, { label: "Delete", icon: <Trash2 />, tone: "danger", onSelect: () => deleteClients(keys).then(() => setSelected([])) }, ]} rowActions={(row) => [{ label: "Edit", icon: <Pencil />, href: `/clients/${row.id}` }]} toolbarActions={[{ label: "New client", icon: <Plus />, primary: true, onSelect: () => router.push("/clients/new") }]} persistKey="clients" /> ); } 

createColumns<Client>() checks every accessor key against the row type, and pinned: "left" becomes columnPinning.start in the TanStack state. Each header's menu can also pin, move or hide a column at runtime, and persistKey remembers visibility, order, widths, density and page size in local storage. The rowActions menu gets a ⋯ column pinned to the end, the same menu on right-click, and Shift+F10 on a focused row.

Live demo: Data Table. The original article embeds an interactive demo here (Select a few rows and the toolbar turns into the bulk bar; open a header's chevron to filter or pin that column, or press the Overdue chip. Below 768px each row is a card: long press one to start selecting). Try it live on wingo-ui.com or open the Data Table docs. Install: npx wingo-ui@latest add data-table

Which filter fits which column?

Pick the filter by the question people ask of the column, and know the value shape, because a server has to parse it. These React data table filters, with the values onColumnFiltersChange and the server query carry:

filter Use it for Value
text names, emails, ids "cafe"
select one owner, one plan "Emma Carter"
multi-select statuses, cities, tags ["Active", "Overdue"]
number-range balances, quantities [1000, null] (either end may be null)
date-range invoice dates, sign-ups { from, to }, each a Date or null (ISO strings once JSON-encoded)

In client mode the select filters list the...