Data Table
Sortable, filterable, paginated table with column visibility, row selection, clickable rows and empty states — built on TanStack Table v8.
| # | ||||
|---|---|---|---|---|
| 1 | Amina Khan | user1@uqudo.com | active | |
| 2 | Omar Haddad | user2@uqudo.com | invited | |
| 3 | Lina Saleh | user3@uqudo.com | suspended | |
| 4 | Yusuf Rahman | user4@uqudo.com | active | |
| 5 | Amina Khan | user5@uqudo.com | invited | |
| 6 | Omar Haddad | user6@uqudo.com | suspended | |
| 7 | Lina Saleh | user7@uqudo.com | active | |
| 8 | Yusuf Rahman | user8@uqudo.com | invited | |
| 9 | Amina Khan | user9@uqudo.com | suspended | |
| 10 | Omar Haddad | user10@uqudo.com | active |
Rows per page
DataTable is a block: one component that wires TanStack Table to the
Table primitive and adds the toolbar, filters,
pagination and empty states every Uqudo list screen needs. You describe the
columns, hand it the rows, and switch features on or off with props. Everything
is client-side — sorting, filtering and paging happen over the data array you
pass in.
datatable.tsx is the entry point and the only file you import from for the
table itself. The other files are the pieces it composes; two of them
(DataTableColumnHeader, RowNumberCell) are meant to be used inside your
column definitions, and DataTableEmptyState inside the emptyState prop.
Installation
npx shadcn@latest add @uqudo/data-tableThe block targets TanStack Table v8 (useReactTable,
ColumnDef<TData, TValue>). The CLI installs @tanstack/react-table@^8; if your
project already has v9 the two APIs are incompatible and the types will fail.
Usage
import type { ColumnDef } from "@tanstack/react-table"
import { DataTable } from "@/components/uqudo/blocks/data-table/datatable"
import { DataTableColumnHeader } from "@/components/uqudo/blocks/data-table/datatable-column-header"Define the columns once, outside the component so their identity is stable:
type User = { id: string; name: string; email: string; status: string }
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
},
{ accessorKey: "email", header: "Email" },
{ accessorKey: "status", header: "Status" },
]Then render:
<DataTable
columns={columns}
data={users}
searchByCol="email"
filters={[{ column: "status", options: ["active", "invited"] }]}
/>DataTable is a client component. Column definitions contain render functions,
so the file that declares them must also be "use client" (or the columns must
be built inside a client component).
How it works
Layout
The component renders three regions from top to bottom:
- Toolbar — search input, filter dropdowns, the "View" column toggle and
your
actionButton. - Table — inside an
overflow-x-autowrapper withmin-w-[600px], so narrow screens scroll horizontally rather than squashing cells. - Pagination — page size select and first/prev/next/last controls.
Each region has its own switch, and the toolbar parts have their own conditions on top:
| Part | Rendered when |
|---|---|
| Search input | searchInput and searchByCol are set |
| Filter dropdowns | filters has at least one entry |
| "View" menu | viewOption (default true) |
actionButton | Provided |
| Pagination | withPagination (default true) |
Turning everything off leaves just the table:
| Code | Country | Region |
|---|---|---|
| AE | United Arab Emirates | Middle East |
| SA | Saudi Arabia | Middle East |
| EG | Egypt | Africa |
| GB | United Kingdom | Europe |
Two toolbars
There are two toolbar markups, toggled with Tailwind's sm breakpoint. On
desktop (sm and up) the search, filters and view menu sit in one row. Below
sm the search collapses to a round icon button; tapping it swaps the whole
toolbar for a full-width search field with a close button, and focus is moved
into the input. Filters and the view menu become round icon buttons too. The
same table instance drives both, so state never gets out of sync when the
viewport changes.
Search
searchByCol names the column the input filters on — a single column, not a
global search. The value is written with column.setFilterValue, which means
TanStack's automatic filter applies: a case-insensitive substring match for
string columns. The column must exist in columns (by accessorKey or id),
otherwise the input does nothing.
Filters
Each entry in filters becomes a dashed dropdown button listing options as
checkboxes. Checked values are stored as an array in the column's filter value;
clearing the last one removes the filter entirely. defaultValue pre-checks one
option on first render — useful for "show open items by default".
| ID | Subject | Priority | State |
|---|---|---|---|
| T-1 | Login loops on Safari | high | open |
| T-2 | Export CSV is empty | medium | open |
| T-5 | Dark mode contrast | low | open |
Columns referenced by filters automatically get an exact-match
multi-select filter function. TanStack's default for string columns is a
substring test against the stringified filter value, so two checked options
("active,pending") would otherwise match nothing. If a column declares its own
filterFn, that one is kept.
The trigger shows the checked values as chips. Labels fall back to the column
id, so pass label for anything that isn't already a readable word.
Sorting and the column header
Sorting is opt-in per column: use DataTableColumnHeader as the column's
header and you get a ghost button that opens Asc / Desc / Hide. Set
enableSorting: false on the column and the same component renders a plain
label at matching size, so mixed headers still line up. Use enableHiding: false to keep a column out of the "Hide" action and the view menu.
The header also stores its title as column.columnDef.meta.label. That is
where the view menu reads names from, so a column using this header shows as
"Created at" in the menu rather than createdAt.
Column visibility
The "View" menu lists every column that has an accessor and can hide — so
select, actions and other display-only columns are excluded, as are columns
with the ids id and hiddenColumn. The label is meta.label, then a string
header, then the column id.
Start columns hidden with initialColumnVisibility; the user can still turn
them on:
<DataTable columns={columns} data={rows} initialColumnVisibility={{ role: false }} />Row selection
Selection state lives inside the component, seeded from rowSelection on first
render, and onRowSelectionChange reports every change. Keys are TanStack row
ids — the row's index in data unless you pass getRowId through a custom
column setup — so map them back to your records yourself. The table only tracks
state; you render the checkboxes in a select column:
Nothing selected
| Invoice | |||
|---|---|---|---|
| INV-001 | Acme Corp | $1,250 | |
| INV-002 | Globex | $480 | |
| INV-003 | Initech | $3,200 | |
| INV-004 | Umbrella | $990 |
Selected rows receive data-state="selected", which the Table primitive styles.
rowSelection is read once. Changing the prop later does not reset the
selection — remount the table (for example with a key) if you need that.
Clickable rows
Pass onRowClick and each row gets role="button", a pointer cursor,
tabIndex={0} and Enter / Space handling. Clicks are ignored when they start
inside a button, a, input, select, textarea, [role="menuitem"] or
anything marked data-no-row-click, so an actions column keeps working without
also opening the row. Omit the prop and rows stay inert.
Click a row, or the delete button.
| Title | Owner | |
|---|---|---|
| Q3 compliance report | Amina | |
| Onboarding checklist | Omar | |
| Vendor contract | Lina |
Empty states
Two different situations are told apart:
datais empty → youremptyStateif provided, else a generic "Nothing here yet".datahas rows but the search/filters match none → always the generic "No matching results", because the fix is to change the filters, not to create data.
Use DataTableEmptyState for a tailored first case — it takes an icon, a
description and an action slot:
| Project | Owner |
|---|---|
No projects yet Projects you create will show up here. | |
Pagination and row numbers
Pagination is client-side with a default page size of 10 and choices of 10 to 50. RowNumberCell renders the row's 1-based position across pages
(pageIndex * pageSize + index + 1), so numbering continues on page 2. Put it in
a display column with enableSorting: false and enableHiding: false; it
positions itself absolutely inside the cell:
{
id: "index",
header: "#",
enableSorting: false,
enableHiding: false,
cell: ({ row, table }) => <RowNumberCell row={row} table={table} />,
}getRowNumber(row, table) is exported for when you need the number without
the markup.
API reference
DataTable
Prop
Type
DataTableFilter
Prop
Type
DataTableColumnHeader
Use as a column's header. Extends HTMLAttributes<HTMLDivElement>.
Prop
Type
RowNumberCell
Prop
Type
DataTableEmptyState
Prop
Type
Source
"use client"
import * as React from "react"
import {
type ColumnDef,
type ColumnFiltersState,
type FilterFn,
type RowData,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Search, X } from "lucide-react"
import { Button } from "@/components/uqudo/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/uqudo/ui/table"
import { DataTableEmptyState } from "./datatable-empty-state"
import { MultiSelectFilters } from "./datatable-multi-select"
import { DataTablePagination } from "./datatable-pagination"
import { SearchBy } from "./datatable-search-by"
import { DataTableViewOptions } from "./datatable-view-options"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
/** Human-readable name shown in the view-options menu. Set by DataTableColumnHeader. */
label?: string
}
}
export type DataTableFilter<TData> = {
/** Column id (the accessor key) the filter applies to. */
column: keyof TData
/** Trigger label. Defaults to the column id. */
label?: string
/** Values offered in the dropdown. Rows match when their cell equals one of the checked values. */
options?: string[]
/** Pre-checked value on first render. */
defaultValue?: string
}
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
/**
* Column the search input filters on. Without it no search input is rendered,
* even when `searchInput` is true.
*/
searchByCol?: keyof TData
rowSelection?: Record<string, boolean>
onRowSelectionChange?: (value: Record<string, boolean>) => void
withPagination?: boolean
viewOption?: boolean
searchInput?: boolean
filters?: DataTableFilter<TData>[]
/** Rendered at the end of the toolbar, e.g. a "Create" button. */
actionButton?: React.ReactNode
/**
* Columns to start hidden, keyed by column id, e.g. `{ caseId: false }`. The
* user can still turn them back on from the view options menu, so use this for
* detail that's useful on demand rather than by default.
*/
initialColumnVisibility?: VisibilityState
/**
* Rendered when the dataset is genuinely empty (no rows at all). Pass a
* tailored <DataTableEmptyState/> here. When the dataset has rows but the
* active search/filters match nothing, a generic "no matching results" state
* is shown instead. Falls back to a generic empty state when omitted.
*/
emptyState?: React.ReactNode
/**
* When provided, the whole row becomes clickable (pointer cursor, hover,
* keyboard-activatable) and this fires with the row's original data. Clicks
* that originate from an interactive child (button, link, input, menu item,
* or anything marked `data-no-row-click`) are ignored so the actions column
* keeps working. Opt-in per table — omit it to keep rows non-interactive.
*/
onRowClick?: (row: TData) => void
}
const INTERACTIVE_CHILD_SELECTOR =
'button, a, input, select, textarea, [role="menuitem"], [data-no-row-click]'
/**
* Exact-match against any of the checked values. TanStack's automatic filter
* for string columns is a substring test on the stringified filter value, so
* `["active", "pending"]` would become `"active,pending"` and match nothing.
*/
const multiSelectFilterFn: FilterFn<unknown> = (row, columnId, values) => {
const selected = values as string[]
return selected.includes(String(row.getValue(columnId)))
}
multiSelectFilterFn.autoRemove = (value: unknown) =>
!Array.isArray(value) || value.length === 0
/** Gives every column that has a dropdown filter the multi-select filter, unless it brings its own. */
function withFilterFns<TData, TValue>(
columns: ColumnDef<TData, TValue>[],
filters: DataTableFilter<TData>[] | undefined
) {
if (!filters?.length) return columns
const filtered = new Set(filters.map((filter) => String(filter.column)))
return columns.map((column) => {
const id =
column.id ??
("accessorKey" in column ? String(column.accessorKey) : undefined)
if (!id || !filtered.has(id) || column.filterFn) return column
return { ...column, filterFn: multiSelectFilterFn as FilterFn<TData> }
})
}
export function DataTable<TData, TValue>({
columns,
data,
searchByCol,
filters,
withPagination = true,
viewOption = true,
searchInput = true,
rowSelection,
onRowSelectionChange,
actionButton,
initialColumnVisibility,
emptyState,
onRowClick,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
() =>
filters?.reduce<ColumnFiltersState>((acc, filter) => {
if (filter.defaultValue) {
acc.push({ id: String(filter.column), value: [filter.defaultValue] })
}
return acc
}, []) ?? []
)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(initialColumnVisibility ?? {})
const [rowSelectionState, setRowSelectionState] = React.useState<
Record<string, boolean>
>(rowSelection ?? {})
const [isMobileSearchOpen, setIsMobileSearchOpen] = React.useState(false)
const mobileSearchRef = React.useRef<HTMLInputElement>(null)
const openMobileSearch = () => {
setIsMobileSearchOpen(true)
requestAnimationFrame(() => mobileSearchRef.current?.focus())
}
const closeMobileSearch = () => {
setIsMobileSearchOpen(false)
requestAnimationFrame(() => mobileSearchRef.current?.blur())
}
const resolvedColumns = React.useMemo(
() => withFilterFns(columns, filters),
[columns, filters]
)
const table = useReactTable({
data,
columns: resolvedColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: (updater) => {
const next =
typeof updater === "function" ? updater(rowSelectionState) : updater
setRowSelectionState(next)
onRowSelectionChange?.(next)
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection: rowSelectionState,
},
})
const rows = table.getRowModel().rows
const hasFilters = Boolean(filters && filters.length > 0)
return (
<div className="w-full max-w-full">
{/* Mobile toolbar: icon buttons, search expands in place. */}
<div className="mb-4 space-y-2 sm:hidden">
{isMobileSearchOpen && searchInput ? (
<div className="flex items-center gap-2">
<SearchBy
table={table}
searchByCol={searchByCol}
wrapperClassName="flex-1"
inputClassName="h-12 rounded-xl"
inputRef={mobileSearchRef}
/>
<Button
variant="ghost"
size="icon"
className="size-10 rounded-full"
onClick={closeMobileSearch}
aria-label="Close search"
>
<X className="size-5" />
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{searchInput && searchByCol && (
<Button
variant="outline"
size="icon"
className="size-10 rounded-full"
onClick={openMobileSearch}
aria-label="Search"
>
<Search className="size-4" />
</Button>
)}
{hasFilters && (
<MultiSelectFilters
filters={filters!}
table={table}
triggerClassName="rounded-full px-4 py-2"
/>
)}
</div>
<div className="flex items-center gap-2">
{viewOption && (
<DataTableViewOptions
table={table}
hideLabel
className="size-10 rounded-full"
/>
)}
{actionButton}
</div>
</div>
)}
</div>
{/* Desktop toolbar. */}
<div className="mb-4 hidden flex-col gap-y-4 sm:flex sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-y-2 sm:flex-row sm:items-center sm:gap-x-4">
{searchInput && searchByCol && (
<SearchBy table={table} searchByCol={searchByCol} />
)}
{hasFilters && (
<MultiSelectFilters filters={filters!} table={table} />
)}
</div>
<div className="flex items-center gap-x-4">
{viewOption && <DataTableViewOptions table={table} />}
{actionButton}
</div>
</div>
<div className="mb-4 overflow-x-auto rounded-md border">
<Table className="w-full min-w-[600px]">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-4 py-2 text-sm">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={onRowClick ? "cursor-pointer" : undefined}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={
onRowClick
? (event) => {
if (
(event.target as HTMLElement).closest(
INTERACTIVE_CHILD_SELECTOR
)
) {
return
}
onRowClick(row.original)
}
: undefined
}
onKeyDown={
onRowClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onRowClick(row.original)
}
}
: undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="relative px-4 py-2 text-sm"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-0">
{data.length === 0 ? (
(emptyState ?? (
<DataTableEmptyState
compact
title="Nothing here yet"
description="There's no data to display right now."
/>
))
) : (
<DataTableEmptyState
compact
title="No matching results"
description="Try adjusting your search or filters to find what you're looking for."
/>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{withPagination && <DataTablePagination table={table} />}
</div>
)
}