Build a React data table with TanStack Table and Tailwind
Build a React data table with TanStack Table v8 and Tailwind: sorting, search, filters, pagination, row selection, bulk actions and column visibility.
By MiniDev19 min read
To build a React data table with TanStack Table and Tailwind, let useReactTable own the state (sorting, filters, pagination, selection, visible columns) and compute the rows, then render those rows with presentational components that only draw what they are given. TanStack Table is headless, so the styling, accessibility and empty states all come from your components.
This guide wires TanStack Table v8 into the free MiniDev UI table parts: DataTable, SortableHeader, TableToolbar, TablePagination, RowSelection, BulkActionsBar, ColumnVisibilityMenu, EmptyTable and LoadingTable. Every prop below is the real prop from the source. If the concept is new, the data table glossary entry covers the vocabulary.
When you need TanStack Table
DataTable on its own already sorts on the client (give a column a sortValue), selects rows, shows a floating bulk bar, a sticky header and a loading skeleton. For a settings page with 30 rows, that is enough. Add TanStack Table when one of these is true:
- You need search, column filters and pagination working together, in the right order.
- The data lives on the server and sorting, filtering and paging must become query parameters.
- Users hide and show columns, or you want multi-column sorting.
- Several parts of the page (toolbar, table, pagination, bulk bar) must read one source of truth.
The rule that keeps this simple: one owner per piece of state. Once TanStack sorts, do not also pass sortValue to DataTable; once TanStack tracks selection, do not also set selectable. The MiniDev parts become paint.
Install
npm i @tanstack/react-table
npx shadcn@latest add https://ui.minidev.pro/r/data-table.json
npx shadcn@latest add https://ui.minidev.pro/r/sortable-header.json
npx shadcn@latest add https://ui.minidev.pro/r/table-toolbar.json
npx shadcn@latest add https://ui.minidev.pro/r/table-pagination.json
npx shadcn@latest add https://ui.minidev.pro/r/row-selection.json
npx shadcn@latest add https://ui.minidev.pro/r/bulk-actions-bar.json
npx shadcn@latest add https://ui.minidev.pro/r/column-visibility-menu.json
npx shadcn@latest add https://ui.minidev.pro/r/empty-table.json
npx shadcn@latest add https://ui.minidev.pro/r/loading-table.json
The shadcn CLI copies each file into components/ui along with its registry dependencies (button, checkbox, search-input, dropdown-menu, skeleton). Import the token stylesheet once, either @import "minidev-ui-kit/styles.css" from the npm package or a copy of styles.css, so classes like bg-surface and text-fg-muted resolve.
Define the data and columns
Column definitions describe how to read, sort, filter and render each field. Define them at module scope so the array keeps the same reference between renders.
"use client"
import type { ColumnDef } from "@tanstack/react-table"
import { SortableHeader } from "@/components/ui/sortable-header"
import { RowSelection } from "@/components/ui/row-selection"
export type Invoice = {
id: string
customer: string
email: string
status: "paid" | "open" | "overdue"
amount: number // cents
issuedAt: string // ISO date
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
const day = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
export const columns: ColumnDef<Invoice>[] = [
{
id: "select",
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<RowSelection
aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(v)}
/>
),
cell: ({ row }) => (
<RowSelection
aria-label={"Select invoice " + row.original.id}
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(v)}
/>
),
},
{
accessorKey: "customer",
header: ({ column }) => (
<SortableHeader label="Customer" direction={column.getIsSorted()} onToggle={() => column.toggleSorting()} />
),
},
{ accessorKey: "email", header: "Email" },
{
accessorKey: "status",
header: "Status",
enableSorting: false,
filterFn: "equalsString",
cell: ({ getValue }) => <span className="capitalize">{getValue<string>()}</span>,
},
{
accessorKey: "amount",
header: ({ column }) => (
<SortableHeader label="Amount" direction={column.getIsSorted()} onToggle={() => column.toggleSorting()} />
),
cell: ({ getValue }) => money.format(getValue<number>() / 100),
},
{
accessorKey: "issuedAt",
header: ({ column }) => (
<SortableHeader label="Issued" direction={column.getIsSorted()} onToggle={() => column.toggleSorting()} />
),
cell: ({ getValue }) => day.format(new Date(getValue<string>())),
},
]
column.getIsSorted() returns false | "asc" | "desc", which is exactly the type of the direction prop on SortableHeader, so no mapping is needed. Calling column.toggleSorting() with no arguments cycles through ascending, descending and unsorted. Text columns start ascending and number columns start descending, which is TanStack's default and usually what people expect for amounts.
Formatters are created once with Intl, and the date formatter pins timeZone: "UTC" so the server render and the browser render agree and you avoid a hydration mismatch.
Wire up useReactTable
Hold each slice of table state in React state and hand it to the table. Controlled state is a little more code than initialState, but it lets the toolbar, the pagination and a URL sync read the same values.
"use client"
import * as React from "react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnFiltersState,
type PaginationState,
type Row,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { DataTable, type Column } from "@/components/ui/data-table"
import { columns, type Invoice } from "./columns"
export function InvoicesTable({ data }: { data: Invoice[] }) {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "issuedAt", desc: true }])
const [globalFilter, setGlobalFilter] = React.useState("")
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({ email: false })
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: 0, pageSize: 20 })
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, columnFilters, columnVisibility, rowSelection, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getRowId: (row) => row.id,
globalFilterFn: "includesString",
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
// ...render, shown in the next sections
}
getCoreRowModelis required. It turnsdataintoRowobjects.getFilteredRowModelapplies the global filter and column filters,getSortedRowModelsorts what is left, andgetPaginationRowModelslices the current page. TanStack runs them in that order regardless of the order you list them.getRowIdmakes selection keys your invoice IDs instead of array indexes, so a selection survives sorting, filtering and a refetch.columnVisibility: { email: false }hides the email column by default. Hidden columns still take part in the global filter, so searching for an email address still finds the row.
data and columns must keep stable references. A new array on every render (an inline [] fallback, or .filter() in the component body) makes TanStack recompute every row model on every render and can cause a render loop. Keep columns at module scope and memoize derived data with useMemo.
Render the rows into DataTable
DataTable is generic over its row type and takes columns of { id, header, cell }, where cell receives a row and returns a node. Pass it TanStack Row objects and let flexRender produce the header and cell content from your column definitions:
const headers = table.getHeaderGroups()[0]?.headers ?? []
const cols: Column<Row<Invoice>>[] = headers.map((header) => ({
id: header.id,
header: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()),
cell: (row) => {
const cell = row.getVisibleCells().find((c) => c.column.id === header.column.id)
return cell ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null
},
align: header.column.id === "amount" ? "right" : "left",
width: header.column.id === "select" ? 40 : undefined,
}))
return (
<DataTable
columns={cols}
data={table.getRowModel().rows}
getRowId={(row) => row.id}
caption="Invoices"
maxHeight={560}
/>
)
table.getRowModel() returns the final rows after filtering, sorting and pagination, and the header group only contains visible columns, so hiding a column removes it from both. No column gets a sortValue, which keeps DataTable from sorting a second time. maxHeight fixes the height and turns on the sticky header, which gains a hairline shadow once the body scrolls. Right aligned cells get tabular-nums automatically, so amounts line up.
Keep aria-sort on the header cell
DataTable sets aria-sort on each th only for columns it sorts itself. With TanStack in charge, add an optional ariaSort field to the Column type in your copy of the file and prefer it when present. It is a two-line change because the component lives in your repo:
// in the Column<T> type
ariaSort?: "ascending" | "descending" | "none"
// on the <th>
aria-sort={c.ariaSort ?? (dir ? (dir === "asc" ? "ascending" : "descending") : c.sortValue ? "none" : undefined)}
Then fill it in the mapping: read header.column.getIsSorted() and return "ascending", "descending" or "none" when header.column.getCanSort() is true.
Search and filters in the toolbar
TableToolbar renders a labelled search field on the left and whatever children you pass on the right. Bind the search to the global filter and put column filters and the column menu in the children slot:
const status = table.getColumn("status")
const overdueOnly = status?.getFilterValue() === "overdue"
<TableToolbar search={globalFilter} onSearchChange={setGlobalFilter} searchPlaceholder="Search invoices…">
<Button
variant="outline"
size="sm"
aria-pressed={overdueOnly}
onClick={() => status?.setFilterValue(overdueOnly ? undefined : "overdue")}
>
Overdue only
</Button>
{/* ColumnVisibilityMenu goes here, see below */}
</TableToolbar>
Setting a filter value to undefined removes that filter. TanStack resets the page index to the first page when filters or sorting change (autoResetPageIndex is on for client side pagination), so users never land on an empty page 7. The equalsString filter function is case insensitive; for multi-select filters use arrIncludesSome and pass an array.
Pagination
TanStack's pageIndex is zero based and TablePagination shows a one based page number, so convert at the boundary. The previous and next buttons disable themselves at the ends and carry aria-labels. See the pagination glossary entry for when to prefer numbered pages or infinite scroll.
<TablePagination
page={pagination.pageIndex + 1}
pageCount={table.getPageCount()}
onPageChange={(page) => table.setPageIndex(page - 1)}
/>
Row selection and bulk actions
The select column above renders RowSelection in the header and in every row. The header uses getIsAllPageRowsSelected and toggleAllPageRowsSelected, so it selects the visible page, which is what a checkbox sitting on top of that page implies. Because rows are keyed by ID, selections on other pages are kept.
The shipped RowSelection passes indeterminate through checked, but the underlying Base UI checkbox has a separate indeterminate prop, so the partial state shows as a full check. In your copy, render <Checkbox checked={checked} indeterminate={indeterminate} ... /> and the header shows a dash when only some rows are selected.
const selected = table.getSelectedRowModel().rows
<BulkActionsBar count={selected.length} onClear={() => table.resetRowSelection()}>
<Button size="sm" variant="outline" onClick={() => exportCsv(selected.map((r) => r.original))}>
Export CSV
</Button>
<Button size="sm" variant="destructive" onClick={() => voidInvoices(selected.map((r) => r.id))}>
Void
</Button>
</BulkActionsBar>
BulkActionsBar renders nothing when count is zero and sticks to the bottom of the scroll area otherwise. It has role="status", so screen readers hear "3 selected" as the count changes. getSelectedRowModel() includes selected rows on every page; use getFilteredSelectedRowModel() if an action should only apply to rows that match the current search.
Show and hide columns
ColumnVisibilityMenu takes a list of { id, label }, the visible IDs as an array, and an onChange with the new array. TanStack stores visibility as a record of booleans, so translate in both directions and only offer columns where getCanHide() is true (the select column opts out with enableHiding: false):
const LABELS: Record<string, string> = { customer: "Customer", email: "Email", amount: "Amount", status: "Status", issuedAt: "Issued" }
const hideable = table.getAllLeafColumns().filter((c) => c.getCanHide())
<ColumnVisibilityMenu
columns={hideable.map((c) => ({ id: c.id, label: LABELS[c.id] ?? c.id }))}
visible={hideable.filter((c) => c.getIsVisible()).map((c) => c.id)}
onChange={(ids) => table.setColumnVisibility(Object.fromEntries(hideable.map((c) => [c.id, ids.includes(c.id)])))}
/>
Persist columnVisibility to localStorage or the user's settings if people come back to this table daily. It is a plain object and serializes as is.
Loading and empty states
There are three different "nothing to show" moments, and each deserves its own UI:
- First load, no data yet. Render
LoadingTablewithrowsandcolsclose to the real shape. It hasrole="status"andaria-busy, so assistive technology knows content is coming. - Refetching a server page. Pass
loadingtoDataTable. The headers stay put and the body shows skeleton rows, which prevents the layout from jumping. - No rows. Pass an
EmptyTableto theemptyprop. Distinguish "you have no invoices" (offer to create one) from "nothing matches your search" (offer to clear filters).
if (isLoading) return <LoadingTable rows={8} cols={5} />
const empty =
data.length === 0 ? (
<EmptyTable title="No invoices yet" description="Invoices you send appear here." actionLabel="New invoice" onAction={openComposer} className="border-0 bg-transparent py-10" />
) : (
<EmptyTable
title="No matching invoices"
description="Try a different search or clear the filters."
actionLabel="Clear filters"
onAction={() => { table.resetGlobalFilter(); table.resetColumnFilters() }}
className="border-0 bg-transparent py-10"
/>
)
<DataTable columns={cols} data={table.getRowModel().rows} getRowId={(row) => row.id} empty={empty} />
Move sorting, filtering and paging to the server
For thousands of rows, let the database do the work. Set the manual* flags, drop the matching row model functions, and tell TanStack how many rows exist so it can compute the page count. With TanStack Query, keepPreviousData keeps the old page on screen while the next one loads:
const EMPTY: Invoice[] = []
const query = useQuery({
queryKey: ["invoices", pagination, sorting, globalFilter, columnFilters],
queryFn: () => fetchInvoices({ pagination, sorting, globalFilter, columnFilters }),
placeholderData: keepPreviousData,
})
const table = useReactTable({
data: query.data?.rows ?? EMPTY,
columns,
rowCount: query.data?.total,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
state: { sorting, globalFilter, columnFilters, columnVisibility, rowSelection, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
})
rowCountneeds TanStack Table 8.13 or later; on older versions passpageCountinstead.- Debounce the search input (around 250ms) before it reaches
globalFilter, or every keystroke becomes a request. - With manual pagination the page index does not reset on its own. Call
table.setPageIndex(0)when the search or a filter changes. - Validate sort columns against an allow list on the server. Never interpolate a client supplied column name into SQL.
Components used in this guide
All of these are free and MIT licensed, and the whole set is on the data tables category page. For the page around the table (sidebar, stat cards, charts), see the Next.js SaaS dashboard guide and the sidebar layout guide. If you want an admin panel built rather than assembled, the MiniDev studio builds complete products on this kit.
Data TableuiReact data table with sortable columns, row selection, a floating bulk actions bar, hover row actions, sticky header, three densities and skeleton loading.npx shadcn@latest add ui.minidev.pro/r/data-table.jsonTable ToolbaruiToolbar above a data table with a search input on the left and a right slot for filters, view toggles or export buttons. Wraps cleanly on narrow screens.npx shadcn@latest add ui.minidev.pro/r/table-toolbar.jsonTable PaginationuiTable pagination footer showing Page X of Y with previous and next icon buttons that disable at the first and last page. Wire it to onPageChange in React.npx shadcn@latest add ui.minidev.pro/r/table-pagination.jsonBulk Actions BaruiFloating Tailwind selection bar that appears once rows are selected, showing the count, your action buttons and a clear button. Sticks to the bottom edge.npx shadcn@latest add ui.minidev.pro/r/bulk-actions-bar.jsonColumn Visibility MenuuiColumns dropdown for data tables with a checkbox item per column. It returns the updated list of visible column ids so you can show or hide table columns.npx shadcn@latest add ui.minidev.pro/r/column-visibility-menu.jsonEmpty TableuiEmpty table placeholder with a dashed border, a No data yet title, helper text and an optional button to create the first row. For React tables.npx shadcn@latest add ui.minidev.pro/r/empty-table.jsonnpx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit
Components used in this guide
Frequently asked questions
Is TanStack Table a component library?
No. TanStack Table is headless: it manages table state and computes row models but renders nothing. You supply the markup, which is where components like DataTable, SortableHeader and TablePagination come in.
How is this different from the shadcn data table?
The pattern is the same (TanStack Table plus your own table markup). The MiniDev parts add a sticky header with a scroll shadow, density options, hover revealed row actions, skeleton loading, a bulk actions bar and empty states, styled with MiniDev tokens.
Why does my TanStack table re-render forever?
Almost always because data or columns is a new array on every render. Define columns outside the component, memoize derived data with useMemo, and use a module level constant for an empty fallback.
Should sorting and pagination run on the client or the server?
Client side is fine up to a few thousand rows that you already have in memory. Beyond that, or when the data changes often, set manualSorting, manualFiltering and manualPagination and send the state to your API as query parameters.