React Data Table Component Examples
Drop-in React Data Table examples: sortable columns, row selection with indeterminate header, search toolbar, column visibility, pagination, and loading state.
Every product eventually grows a screen that lists records — users, invoices, deployments, audit events — and that screen needs sorting, selection, an empty state, and something to show while the fetch is still in flight. Drivn's DataTable covers all of it in a single component with no table library underneath. There is no TanStack core to install, no useReactTable hook to configure, and no row model to assemble before the first row paints. You hand it data, columns, and rowKey, then opt into behaviour with booleans: sortable, selectable, loading.
Columns are plain objects — { id, header, cell, sortable, align, width } — so a column list reads at a glance, survives a JSON round-trip, and can be generated from a schema when your fields are dynamic. Running npx drivn add data-table writes the file to @/components/ui/data-table, the same import path every example on this page uses. From that point the sort comparator, the selection Set, the skeleton branch, and the "No results." row all live in your repo and are yours to edit. For how the API differs from the shadcn/ui recipe built on TanStack Table, read Drivn vs shadcn Data Table, or check the Drivn CLI reference first.
The examples run from the smallest table that renders through sortable headers, row selection, a search toolbar, badge cells, pagination, and the loading skeleton. Copy the closest one and swap in your own column shape.
Basic table with three columns
The starting point is the smallest invocation that renders. Pass an array of row data, an array of column definitions, and the property name on each row that uniquely identifies it. The column shape is { id, header } at minimum — the id matches a key on the row, and the header is the visible label. Every other column knob (cell, align, width, sortable) is optional and additive. The component handles the rest: it renders the Table primitive internally with proper Table.Header, Table.Row, and Table.Cell markup. No state, no hook wiring, no toolbar. This is the form most list views start with before they grow features.
1 import { DataTable } from "@/components/ui/data-table" 2 3 const columns = [ 4 { id: "name", header: "Name" }, 5 { id: "email", header: "Email" }, 6 { id: "role", header: "Role" }, 7 ] 8 9 const users = [ 10 { id: "1", name: "Alice Johnson", email: "alice@example.com", role: "Admin" }, 11 { id: "2", name: "Bob Smith", email: "bob@example.com", role: "Editor" }, 12 { id: "3", name: "Charlie Brown", email: "charlie@example.com", role: "Viewer" }, 13 ] 14 15 export default function Page() { 16 return ( 17 <DataTable 18 data={users} 19 columns={columns} 20 rowKey="id" 21 /> 22 ) 23 }
Sortable columns with onSortChange
Add sortable to the root and every column gets a clickable header with an arrow indicator. The internal useMemo re-sorts the data array on the active column id, comparing numbers with subtraction and everything else via String(...).localeCompare(...). Click the same header twice to flip direction; click a third time to clear the sort. To override sortability per column, set sortable: true or sortable: false on a specific column definition — the column-level value wins over the root flag. The onSortChange callback fires with { id, direction } whenever the active sort changes, which is what you wire to a server-side fetch when the table is paginated by the API. The icons come from lucide-react: ArrowUp and ArrowDown for active columns, ArrowUpDown when neutral.
1 <DataTable 2 data={users} 3 columns={[ 4 { id: "name", header: "Name" }, 5 { id: "email", header: "Email" }, 6 { id: "role", header: "Role", sortable: false }, 7 ]} 8 rowKey="id" 9 sortable 10 onSortChange={(sort) => console.log(sort.id, sort.direction)} 11 />
Row selection with indeterminate header
Pass selectable and a leading checkbox column appears. The header checkbox tracks the visible row keys: when none are selected it shows unchecked, when all are selected it shows checked, and when some are selected it shows indeterminate via a small useEffect that flips ref.current.indeterminate. The onSelectionChange callback receives a Set<string> of selected row keys, where each key is the value at row[rowKey] coerced to a string. Selected rows pick up the bg-primary/5 highlight from the styles object. Use the count to drive bulk-action toolbars or to gate destructive actions until at least one row is selected. The underlying primitive is the same Checkbox the rest of Drivn uses, so its keyboard and focus behavior is consistent across forms and tables.
1 import { useState } from "react" 2 import { DataTable } from "@/components/ui/data-table" 3 4 const [selected, setSelected] = useState<Set<string>>(new Set()) 5 6 <DataTable 7 data={users} 8 columns={columns} 9 rowKey="id" 10 selectable 11 onSelectionChange={setSelected} 12 toolbar={ 13 <p className="text-sm text-muted-foreground"> 14 {selected.size} of {users.length} selected 15 </p> 16 } 17 />
Search toolbar above the table
The toolbar prop accepts any React node and renders it above the table inside the same space-y-4 container. The most common toolbar is a search Input that filters the rows in your component before they reach data. Drivn does not own the filter — you do. That keeps the data lifecycle explicit: filter your array, pass the result, and the component renders what you give it. Pair the input with useState<string> on the search term and a users.filter(...) call against whichever fields you want to search. For server-side filtering, debounce the input and call your API on change, then set the response as data.
1 import { useState } from "react" 2 import { DataTable } from "@/components/ui/data-table" 3 import { Input } from "@/components/ui/input" 4 5 const [search, setSearch] = useState("") 6 const filtered = users.filter((u) => 7 u.email.toLowerCase().includes(search.toLowerCase()) 8 ) 9 10 <DataTable 11 data={filtered} 12 columns={columns} 13 rowKey="id" 14 toolbar={ 15 <Input 16 placeholder="Search by email..." 17 value={search} 18 onChange={(e) => setSearch(e.target.value)} 19 className="max-w-xs" 20 /> 21 } 22 />
Custom cell renderer with Badge
The cell property on a column definition is a function that receives the column value and the full row, and returns a React node. Use it for status pills, formatted dates, currency, avatars, or action icons. The default renderer coerces the value to a string via String(value ?? "") so missing values render as empty rather than as undefined. For status columns the canonical pattern is a Badge whose variant is mapped from the value — default for healthy, secondary for pending, destructive for failed. Because the cell function gets the full row, you can compose multiple fields in one cell — for example, an avatar plus a name in the user column.
1 import { DataTable } from "@/components/ui/data-table" 2 import { Badge } from "@/components/ui/badge" 3 4 <DataTable 5 data={users} 6 columns={[ 7 { id: "name", header: "Name" }, 8 { id: "role", header: "Role" }, 9 { 10 id: "status", 11 header: "Status", 12 cell: (value) => ( 13 <Badge 14 variant={value === "Active" ? "default" : "secondary"} 15 > 16 {String(value)} 17 </Badge> 18 ), 19 }, 20 ]} 21 rowKey="id" 22 />
Pagination via DataTable.Pagination
Pagination is a sub-component on the same import — DataTable.Pagination — exposed via Object.assign(DataTableRoot, { Pagination: DataTablePagination }). Render it inside the footer prop with page, pageCount, and onPageChange. The label reads Page x of y, and the Pagination primitive supplies the Previous and Next controls, which set aria-disabled when the boundaries hit. Page state lives in your component, which means client-side and server-side pagination use the same JSX — only the source of data changes. For client-side pagination, slice the array; for server-side, fetch a page and set it as data while updating pageCount from the response total.
1 import { useState } from "react" 2 import { DataTable } from "@/components/ui/data-table" 3 4 const [page, setPage] = useState(1) 5 const pageSize = 3 6 const pageData = users.slice((page - 1) * pageSize, page * pageSize) 7 const pageCount = Math.ceil(users.length / pageSize) 8 9 <DataTable 10 data={pageData} 11 columns={columns} 12 rowKey="id" 13 footer={ 14 <DataTable.Pagination 15 page={page} 16 pageCount={pageCount} 17 onPageChange={setPage} 18 /> 19 } 20 />
Loading skeleton and empty state
Two states matter before the rows arrive. With loading set, the body renders Array.from({ length: 5 }) placeholder rows — one Skeleton bar per column, each sized by style={{ width: col.width ?? '60%' }} so the bars line up with the columns they stand in for and nothing jumps when real data lands. Only columns are mapped in this branch, so the selection cell is skipped while loading. When loading is false and data is empty, the table renders one row whose cell spans columns.length + (selectable ? 1 : 0) and centers "No results." through styles.empty — the right outcome for "no rows match this filter" as well as a genuinely empty collection. In the source the three cases are one ternary chain: loading, then empty, then rows. Both branches are literals in your copy of the file, so changing the placeholder count or the empty-state copy is a one-line edit rather than a config option you hope exists.
1 // Loading state 2 <DataTable 3 data={[]} 4 columns={columns} 5 rowKey="id" 6 loading 7 /> 8 9 // Empty state — same component, no loading flag 10 <DataTable 11 data={[]} 12 columns={columns} 13 rowKey="id" 14 />
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
Yes. The component imports React, the cn utility, and four Drivn primitives — Table, Checkbox, Skeleton, and Pagination — and nothing else. Sorting is a useMemo over the data array. Row selection is a useState<Set<string>> plus a small useEffect that drives the indeterminate flag on the header checkbox. There is no useReactTable, no row model, and no plugin pipeline. The whole feature ships in roughly two hundred lines of TSX you can read end to end.
Type the row shape and pass it as the generic to a typed columns array. The ColumnDef<T> interface in the source declares id: string, header: string, optional cell: (value: unknown, row: T) => React.ReactNode, and the alignment and width knobs. The simplest pattern is to define type User = { id: string; name: string; email: string } and then write const columns: ColumnDef<User>[] = [...] — TypeScript narrows the row parameter inside cell to User so you can read row fields without casts.
Yes. The cell function returns any React node, so you can render a Button, a Dropdown, or an icon link directly inside a cell. For row-action menus the canonical pattern is a final actions column with a Dropdown trigger and a list of items that operate on the row data passed to the cell function. Selection state and the row click area do not interfere with focus inside the cell — the row highlight is purely visual, driven by the bg-primary/5 className on the row when its key is in the selected Set.
Drive the loading prop from a separate state flag — typically isFetching from your data fetcher — without clearing the data array. When loading is true, the body unconditionally renders the skeleton rows regardless of what data contains, so a refetch can paint the loading state over the previous results. When loading flips back to false, the body renders the new data. Selection state and sort state live in component state, so a refetch does not lose them.
Not as a roving tabindex. Drivn's rows are static <tr> elements; tab order moves through the focusable elements inside cells (the header sort buttons, the row checkboxes, and any cell-level buttons or links). Arrow-key navigation across rows the way a native grid would handle it is not built in. For most product tables — list views, dashboards, admin panels — tabbing through cells is the expected pattern. For full grid-style keyboard navigation, the file is small enough to extend with aria-rowindex, a roving tabindex, and arrow-key handlers on the row.