Skip to content
Drivn
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.

Tables are where dependency decisions stop being abstract. shadcn/ui does not publish a DataTable component at all — it publishes a recipe. You install @tanstack/react-table, declare your columns through createColumnHelper, call useReactTable inside the page, and then hand-render header groups and row models into <Table> markup with flexRender. Roughly seventy lines of composition end up living in your application file, and every capability past the core row model — sorting, row selection, column visibility, pagination — arrives as another plugin you opt into plus another slice of state you hold.

Drivn goes the other direction. DataTable is one component whose complete import list is React, the cn helper, and four sibling primitives: Table, Checkbox, Skeleton, and Pagination. No table engine sits underneath it. Sorting is a useMemo comparator, selection is a Set<string> held in state, the indeterminate header checkbox is a ref plus a small effect, and the loading and empty branches are written directly into the body render. You hand it data, columns, and rowKey, then switch features on with boolean props. What you give up is TanStack's long tail — faceted filters, column resizing, grouping. What you get back is roughly two hundred lines of TSX you own after npx drivn add data-table, with nothing new in node_modules to track.

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

The dependency question has a concrete answer here. shadcn/ui's table recipe stands on @tanstack/react-table (formerly react-table), a headless table runtime of roughly thirty kilobytes that brings its own column model, row model, and opt-in feature plugins — faceted filtering, column pinning, grouping, expanded rows, virtualization adapters. It is built to cover every table pattern the ecosystem has produced, and it does. Drivn's DataTable imports React, the cn utility, Table, Checkbox, Skeleton, and Pagination, and that list is complete; nothing sits below it. Sorting is a single useMemo that finds the active column, multiplies by a direction factor, compares with subtraction for numbers and localeCompare for everything else, and pushes nullish values to the end regardless of direction. Selection is a useState<Set<string>>. That is the entire model. The trade is stated rather than hidden: TanStack owns the long tail, Drivn owns the eighty-percent table that fills a product's list views. Mixing the two is fine — keep the shadcn recipe on the one grid that genuinely needs pinning and faceting, and use the Drivn CLI to add DataTable everywhere else without touching that grid.

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.