Skip to content
Drivn logoDrivn
6 min read

Drivn vs shadcn/ui — Data Table Compared

Side-by-side comparison of Drivn DataTable vs the shadcn/ui TanStack Table recipe — built-in sorting and selection, zero deps, column API, and bundle cost.

Drivn and shadcn/ui both let you put a real interactive table on a page, but the two approaches sit on opposite ends of the dependency line. shadcn/ui does not ship a DataTable component — its docs publish a recipe that wires @tanstack/react-table into your application file, where you supply column definitions, a useReactTable hook call, and roughly seventy lines of composition that paint the headers, body, and pagination. The data engine — sorting, filtering, row selection, column visibility, expanded rows — lives inside TanStack Table, a generic, framework-agnostic library that adds about thirty kilobytes of vendor JavaScript and expects you to wire its model to your JSX yourself.

Drivn ships DataTable as a single component that imports React, the local Table, Checkbox, Skeleton, and Pagination, and nothing else. Sorting, row selection with indeterminate header checkbox, loading skeletons, and the empty state all live inside the component file. You pass data, columns, and rowKey; you flip on sortable or selectable with a boolean prop. The table is roughly two hundred lines of TSX you own after npx drivn add data-table, with no TanStack runtime, no model, and no per-page wiring — at the cost of a smaller surface than TanStack exposes.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying table engineNone — pure React + Tailwind@tanstack/react-table v8
Bundle cost beyond React0 runtime deps~30kb TanStack runtime
Setup per table<DataTable data columns rowKey />useReactTable + composed JSX
Column definition shape{ id, header, cell, sortable }createColumnHelper / accessorKey
Sortingsortable boolean propgetSortedRowModel + state
Row selectionselectable + onSelectionChangerowSelection state + helpers
Loading skeleton rowsloading prop, built inRender Skeleton manually
Empty state"No results." built inManual TableRow fallback
Pagination componentDataTable.Pagination subcomponentgetPaginationRowModel + buttons
Faceted filters / column resizing

API side-by-side — what you write per table

shadcn/ui hands you a recipe; Drivn hands you a component. With shadcn you import useReactTable, getCoreRowModel, optionally getSortedRowModel and getPaginationRowModel, declare columns via createColumnHelper, call useReactTable({ data, columns, ... }), then render the <Table> markup using table.getHeaderGroups().map(...) and table.getRowModel().rows.map(...). Each table you build pays that wiring cost again. Drivn collapses that surface to one JSX element — <DataTable data={users} columns={columns} rowKey="id" sortable selectable /> — and the column definition is plain data: { id, header, cell, sortable, align, width }. The full prop set lives in the Data Table docs. For applications where every list view becomes a table, the difference is the table being a one-liner instead of a fifty-line block.

1// shadcn/ui — TanStack Table recipe
2import {
3 useReactTable,
4 getCoreRowModel,
5 getSortedRowModel,
6 flexRender,
7 createColumnHelper,
8} from '@tanstack/react-table'
9import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
10
11const columnHelper = createColumnHelper<User>()
12const columns = [
13 columnHelper.accessor('name', { header: 'Name' }),
14 columnHelper.accessor('email', { header: 'Email' }),
15 columnHelper.accessor('role', { header: 'Role' }),
16]
17
18const table = useReactTable({
19 data,
20 columns,
21 getCoreRowModel: getCoreRowModel(),
22 getSortedRowModel: getSortedRowModel(),
23 state: { sorting },
24 onSortingChange: setSorting,
25})
26
27<Table>
28 <TableHeader>
29 {table.getHeaderGroups().map(group => (
30 <TableRow key={group.id}>
31 {group.headers.map(header => (
32 <TableHead key={header.id}>
33 {flexRender(header.column.columnDef.header, header.getContext())}
34 </TableHead>
35 ))}
36 </TableRow>
37 ))}
38 </TableHeader>
39 <TableBody>
40 {table.getRowModel().rows.map(row => (
41 <TableRow key={row.id}>
42 {row.getVisibleCells().map(cell => (
43 <TableCell key={cell.id}>
44 {flexRender(cell.column.columnDef.cell, cell.getContext())}
45 </TableCell>
46 ))}
47 </TableRow>
48 ))}
49 </TableBody>
50</Table>
51
52// Drivn — single component
53import { DataTable } from '@/components/ui/data-table'
54
55const columns = [
56 { id: 'name', header: 'Name' },
57 { id: 'email', header: 'Email' },
58 { id: 'role', header: 'Role' },
59]
60
61<DataTable data={users} columns={columns} rowKey="id" sortable />

Zero runtime deps vs TanStack Table

shadcn/ui's table recipe sits on top of @tanstack/react-table (formerly react-table) — a generic table runtime that ships its own column model, row model, and feature plugins. The library is roughly thirty kilobytes of vendor JavaScript on top of React and is built to support every pattern the ecosystem has invented for tables: faceted filters, column pinning, virtualization adapters, expanded rows, grouping. Drivn ships none of that. The DataTable file imports React, Table, Checkbox, Skeleton, Pagination, and the cn utility — that is the entire dependency list. Sorting is a useMemo over the data array that compares by the column id with localeCompare for strings and subtraction for numbers. Selection is a useState<Set<string>>. The trade is honest: TanStack covers the long tail; Drivn covers the eighty-percent table you ship in product views. If your application already pays the TanStack cost for one critical grid, you can keep using shadcn there and reach for Drivn for the rest. See the Drivn CLI for how to add it to a single page without touching the rest of the project.

1const sorted = React.useMemo(() => {
2 if (!sort) return data
3 const col = columns.find(c => c.id === sort.id)
4 if (!col) return data
5 const m = sort.direction === 'asc' ? 1 : -1
6 return [...data].sort((a, b) => {
7 const x = (a as Row)[col.id], y = (b as Row)[col.id]
8 if (x == null) return 1
9 if (y == null) return -1
10 return (typeof x === 'number' && typeof y === 'number'
11 ? x - y : String(x).localeCompare(String(y))) * m
12 })
13}, [data, sort, columns])

Row selection with indeterminate header checkbox

Selecting rows is the place TanStack and Drivn end up at almost the same UX with very different code. In the TanStack recipe you opt into row selection by adding a custom column whose header renders a <Checkbox> bound to table.getIsAllPageRowsSelected() and table.toggleAllPageRowsSelected, and whose cell renders a per-row <Checkbox> bound to row.getIsSelected() and row.toggleSelected. The selection state lives inside the TanStack table instance and you read it via table.getSelectedRowModel().rows. Drivn moves both checkboxes and the indeterminate state into the component. Pass selectable and the leading select column appears with a header Checkbox whose indeterminate flag is set by a useEffect that compares selected.size against the visible row keys. The onSelectionChange callback hands you a Set<string> of selected row keys directly. The visual primitive is the same Drivn Checkbox used everywhere else, and the indeterminate ref pattern is the same one the source documents.

1const keys = React.useMemo(() => sorted.map(r => `${(r as Row)[rowKey]}`), [sorted, rowKey])
2const allSelected = keys.length > 0 && keys.every(k => selected.has(k))
3
4React.useEffect(() => {
5 if (ref.current) {
6 ref.current.indeterminate = keys.some(k => selected.has(k)) && !allSelected
7 }
8}, [selected, keys, allSelected])
9
10// header
11<Checkbox
12 ref={ref}
13 checked={allSelected}
14 onChange={() => setSelection(allSelected ? new Set() : new Set(keys))}
15/>
16
17// row
18<Checkbox checked={selected.has(k)} onChange={() => toggle(k)} />

Loading state and empty state, included

Two states that almost every product table needs are not in the TanStack recipe by default — you render a Skeleton row yourself when isLoading, and you render a fallback <TableRow> with a <TableCell colSpan={...}> when data.length === 0. Drivn bakes both into the component. Pass loading and the body renders five rows of Skeleton whose width matches each column's width prop, falling back to 60% when no width is set. When the data array is empty, the body renders a centered "No results." message in a single full-span cell. Both behaviors live inside the conditional render in the source — the loading branch comes first, the empty branch second, the data branch third — and you can extend either by editing the component file. The states pair naturally with the Skeleton component used across Drivn for any deferred render.

1<Table.Body>
2 {loading ? (
3 Array.from({ length: 5 }, (_, i) => (
4 <Table.Row key={i}>
5 {columns.map(col => (
6 <Table.Cell key={col.id}>
7 <Skeleton className={styles.skeleton} style={{ width: col.width ?? '60%' }} />
8 </Table.Cell>
9 ))}
10 </Table.Row>
11 ))
12 ) : sorted.length === 0 ? (
13 <Table.Row>
14 <Table.Cell colSpan={columns.length + (selectable ? 1 : 0)}>
15 <div className={styles.empty}>No results.</div>
16 </Table.Cell>
17 </Table.Row>
18 ) : (
19 sorted.map(r => { /* render row */ })
20 )}
21</Table.Body>

Pagination — subcomponent vs row models

Pagination is the second half of any table that lists more than fifty rows. The TanStack approach gives you getPaginationRowModel, table.getState().pagination, table.previousPage(), table.nextPage(), and you wire those to a footer with two buttons and a "Page x of y" label. Drivn ships DataTable.Pagination as a sub-component on the same object via Object.assign(DataTableRoot, { Pagination: DataTablePagination }), so you write <DataTable.Pagination page={page} pageCount={pageCount} onPageChange={setPage} /> inside the footer prop. The component renders the Page x of y label plus a Pagination Previous and Next pair with aria-disabled set when the boundaries hit. Page state lives in your component, which keeps the data-fetching pattern explicit — the table does not own the page; you do. The trade is the same as elsewhere: TanStack ships more rope (jumping to a page, page-size selectors), Drivn ships the common path with a smaller surface.

1const [page, setPage] = useState(1)
2const pageSize = 3
3const pageData = users.slice((page - 1) * pageSize, page * pageSize)
4const pageCount = Math.ceil(users.length / pageSize)
5
6<DataTable
7 data={pageData}
8 columns={columns}
9 rowKey="id"
10 footer={
11 <DataTable.Pagination
12 page={page}
13 pageCount={pageCount}
14 onPageChange={setPage}
15 />
16 }
17/>
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

No. The component imports React, the cn utility, and four other Drivn components — Table, Checkbox, Skeleton, and Pagination — and nothing else. Sorting is a useMemo over the data array, selection is a useState<Set<string>>, and the indeterminate header checkbox is wired through a React.useRef plus a small useEffect. shadcn/ui's data-table recipe pulls in @tanstack/react-table (about thirty kilobytes of vendor JS) and expects you to compose its row model into your JSX yourself. Drivn skips that runtime entirely.

Not directly through the sortable prop. Drivn's sort comparator reads row[col.id] from the underlying data, so the sortable column id has to match a real key on the row. For computed values — full name from first plus last, total from line items — pre-compute the value into the data array before passing it in, or hold sort state externally and pass the already-sorted array to data while leaving the column non-sortable. The onSortChange callback fires with the active sort so you can mirror it server-side when you fetch the next page.

Filter the columns array before passing it to DataTable. Hold a useState<Set<string>> of hidden column ids, render a Select or Dropdown of Checkbox rows that toggle membership in that Set, and pass columns.filter(c => !hidden.has(c.id)) to the columns prop. Because columns are plain data and the table re-renders on prop change, the visibility toggle works without any internal state — the example on the DataTable docs page shows the exact pattern.

Not in the current implementation. Drivn covers the patterns most product tables actually need — sorting, single-column row selection, search via a custom toolbar, column visibility via the columns prop, and pagination via the sub-component. Faceted filters with multi-select chips, drag-to-resize column widths, column pinning, and grouped/expanded rows live on the TanStack side. If your table needs those features, the shadcn recipe is a better fit. If your table is the standard list view in a SaaS product, the Drivn DataTable is roughly two hundred lines you own end to end and can extend in place.

Yes. The data prop is the array you choose to render, so any external pagination or sorting model works — fetch a page, set it as data, render DataTable.Pagination with the totals, and update on onPageChange. For server-side sorting, listen on onSortChange and re-fetch when the sort changes. The internal client-side sort still runs on whatever you pass in, so for purely server-driven tables you set sortable={false} on the root and add a custom sort header in your toolbar that drives the request. The component does not assume the table owns the data lifecycle.