Skip to content
Drivn logoDrivn
6 min read

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.

A data table is the workhorse of any product surface that lists records — users, orders, deployments, invoices, anything you scan in rows. The DataTable component in Drivn ships with sorting, row selection, loading skeletons, an empty state, and a pagination sub-component built in. There is no TanStack runtime, no row-model wiring, and no per-table boilerplate. You pass data, columns, and rowKey; you flip on sortable or selectable with a boolean prop. The column definition is plain data — { id, header, cell, sortable, align, width } — so columns survive a JSON round-trip and stay readable when you scan the file.

Each example below imports DataTable from @/components/ui/data-table, the same path the Drivn CLI writes the file to. After npx drivn add data-table the component lives in your repo and you own the implementation — sort comparator, selection state, loading branch, empty state — across roughly two hundred lines of TSX. To see how the API differs from the shadcn/ui TanStack-based recipe, read Drivn vs shadcn Data Table.

The patterns below cover the cases real product tables hit: a basic table, sortable columns, row selection with the indeterminate header checkbox, a custom toolbar with a search input, column visibility toggles, custom cell renderers like badges, paginated tables, and the loading skeleton. Pick the closest match and swap in your 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.

1import { DataTable } from "@/components/ui/data-table"
2
3const columns = [
4 { id: "name", header: "Name" },
5 { id: "email", header: "Email" },
6 { id: "role", header: "Role" },
7]
8
9const 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
15export 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.

1import { useState } from "react"
2import { DataTable } from "@/components/ui/data-table"
3
4const [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.

1import { useState } from "react"
2import { DataTable } from "@/components/ui/data-table"
3import { Input } from "@/components/ui/input"
4
5const [search, setSearch] = useState("")
6const 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.

1import { DataTable } from "@/components/ui/data-table"
2import { 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.

1import { useState } from "react"
2import { DataTable } from "@/components/ui/data-table"
3
4const [page, setPage] = useState(1)
5const pageSize = 3
6const pageData = users.slice((page - 1) * pageSize, page * pageSize)
7const 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

Pass loading and the body renders five rows of Skeleton bars whose width matches each column's width prop, falling back to 60% when no width is set. This avoids the layout shift that hits when a real table replaces a spinner. When data is an empty array and loading is false, the body renders a single full-span row with a centered "No results." message — useful for "no rows match this filter" outcomes. Both states are inside the same conditional render in the source: loading first, empty second, data third. To customize the empty state copy, edit styles.empty and the inline "No results." literal in the component file after install.

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/>
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

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.