TanStack Table v9 Data Table: Filters, Bulk Actions, Mobile
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 (opens in a new tab) as of October 2026:
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 (opens in a new tab) (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:
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:
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 (opens in a new tab) 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:
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.
$ npx wingo-ui@latest add data-tableWhich 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:
In client mode the select filters list the column's unique values with counts, and the search box matches every word across the searchable columns, accents and formatted values included: "cafe" finds Café and "12480" finds $12,480.00. Quick filters are chips for the two or three questions someone asks every morning. A chip with a columnFilter works in both modes; a chip with a filter function runs in the browser only.
How should bulk actions work across pages?
Act on the ids, not on the row objects. bulkActions receives (rows, keys): keys holds every selected id, including rows on other pages or hidden by a filter, while rows only holds the selected rows present in data. In server mode that is the current page, so an export built from rows silently drops everything selected on page 1 once you are on page 3.
While rows are selected the Toolbar switches to its bulk mode: an inline band on desktop, a floating bar on phones. The rest follows Gmail. Selecting a whole page in client mode shows a banner that offers every matching row, and rows that a new filter hides stay selected, with the banner saying how many. Shift-click selects a range, Ctrl or Cmd + A does what the header checkbox does, Escape clears. A bulk action that returns a promise shows its button loading until it settles, and a danger tone stays red, the floating phone bar included. For "Delete 240 clients" we still open an Alert Dialog from onSelect: a bulk delete deserves one more tap.
Clear the selection after an action removes rows. The table never drops a selected id on its own, since a missing id may sit on another page, so after a delete the bar would still count rows that no longer exist. That is why the example above controls selectedKeys.
How do you set up server-side pagination in TanStack Table?
Tell the table the server owns the work, give it the total, and fetch whenever the state changes. TanStack Table server side pagination in v9 is three flags and a count, and the row model factories can go, because the browser never sorts or slices:
The Data Table folds that wiring into mode="server": data is the current page, rowCount the total, and every change arrives in onQueryChange as one object with page, pageSize, sorting, globalFilter and columnFilters. That object is a ready-made query key for TanStack Query:
keepPreviousData keeps the last page on screen while the next one loads, and refreshing slides a thin bar along the top edge instead of swapping the rows for skeletons. The search arrives debounced (300ms), and the page goes back to 1 when the search, a filter or the sort changes.
The route handler treats the query as untrusted input. Sort ids and page sizes come from a whitelist, and each filter id maps to a clause you wrote:
Two server-mode details. The table has no faceted values without the full data, so a select filter lists only its filterOptions, or the badge map's keys on a badge column: status works as is, while city and owner need an explicit filterOptions list (a select distinct on the server, for example). And quick filters need a columnFilter, since a filter function never reaches the server. If the page, sort and filters should live in the URL so a link reopens the same view, the Next.js admin dashboard tutorial does that with router.replace and a server component.
$ npx wingo-ui@latest add data-tableServer mode has a cost: every pause in typing is a request. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch past a few thousand rows, or when permissions decide which rows exist for a user.
What should a data table look like on a phone?
A card per row. Eight columns on a 390px screen show about two at a time, and the header that names them scrolls out of sight. Below 768px the Data Table renders each row as a card, built from the same mobile roles you saw in the columns:
titleis the card heading (by default the first column),subtitlethe line under it.metasits in the top corner (by default the first badge or the last number column).- Up to four
fieldcolumns show as label and value pairs;hiddenkeeps a column off the card.
Long press a card to start selecting, then tap to add more, and the bulk bar floats at the bottom of the screen. Sort, filters and the row menu open as bottom sheets, and client mode appends rows with Load more while server mode keeps a compact Pagination pager. The switch is a CSS breakpoint, so the server HTML already has the right layout before hydration and nothing jumps. renderCard replaces the mapping with your own card, and mobileLayout="scroll" keeps the table scrolling sideways for the rare grid that people compare column by column. The reasoning behind those defaults, with 44px touch targets and bottom sheets, is in mobile-first React components.
For small read-only tables, such as invoice lines or a price list, the plain Table does the same card switch without TanStack, selection or a toolbar.
Should you build on TanStack directly or use a component?
Build directly when the table is one of a kind or when your design system already has the menus and sheets; use a component when you need the same admin table on twenty screens. The options we would consider, as of October 2026:
If you want Notion-style advanced filters over a Postgres table, tablecn is the better free starting point. If you only need sorting, one text filter and client pagination, the shadcn guide is enough. The Data Table, with the Table, Pagination and Toolbar it builds on, is part of Wingo UI Pro.
What should you build next?
Open the playground on the Data Table docs, switch mode to server and watch the query object change as you sort and filter, then install it with npx wingo-ui@latest add data-table after a one-time CLI login. Other tables in the library, from the members table to the editable grid, are listed under tables, and more component tutorials live in the component guides category, starting with the React form components guide for the filter forms around your tables.
FAQ
What replaced useReactTable in TanStack Table v9?
useTable, which takes a required features option built with tableFeatures(). Row models such as sorting and pagination moved into that features object as factories (sortedRowModel: createSortedRowModel()), and useLegacyTable from @tanstack/react-table/legacy keeps the v8 API as a deprecated migration aid.
How do I do server-side pagination in TanStack Table v9?
Set manualPagination: true, pass the total as rowCount, control the pagination state and fetch the page your API returns for it. Add manualSorting and manualFiltering when the server also sorts and filters, and leave the matching row model factories out of tableFeatures, since the browser never runs them.
How do I pin columns in TanStack Table v9?
Register columnPinningFeature and set columnPinning to { start: [...], end: [...] }; v9 renamed left and right to start and end with no aliases. Register columnSizingFeature too, make the pinned cells position: sticky with column.getStart('start') or column.getAfter('end') as the offset, and give them a width and a background so the offsets line up and rows do not show through.
Does the shadcn data table work on phones?
As of October 2026 the shadcn data table guide covers sorting, filtering, column visibility, client pagination and row selection on TanStack Table v9, and has no phone layout: the table keeps its columns and scrolls sideways. A card per row below 768px is something you add yourself or get from a component that ships it.
Is the Wingo UI Data Table free?
No. The Data Table is part of Wingo UI Pro, and so are the Table, Pagination and Toolbar it builds on. The checkbox, dropdown menu, popover and other basics it uses are free on their own.
- TanStack Table
- Data table
- React
- shadcn/ui
- Mobile UI
- Next.js