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
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Underlying table engine | None — pure React + Tailwind | @tanstack/react-table v8 |
| Bundle cost beyond React | 0 runtime deps | ~30kb TanStack runtime |
| Setup per table | <DataTable data columns rowKey /> | useReactTable + composed JSX |
| Column definition shape | { id, header, cell, sortable } | createColumnHelper / accessorKey |
| Sorting | sortable boolean prop | getSortedRowModel + state |
| Row selection | selectable + onSelectionChange | rowSelection state + helpers |
| Loading skeleton rows | loading prop, built in | Render Skeleton manually |
| Empty state | "No results." built in | Manual TableRow fallback |
| Pagination component | DataTable.Pagination subcomponent | getPaginationRowModel + 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 2 import { 3 useReactTable, 4 getCoreRowModel, 5 getSortedRowModel, 6 flexRender, 7 createColumnHelper, 8 } from '@tanstack/react-table' 9 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' 10 11 const columnHelper = createColumnHelper<User>() 12 const columns = [ 13 columnHelper.accessor('name', { header: 'Name' }), 14 columnHelper.accessor('email', { header: 'Email' }), 15 columnHelper.accessor('role', { header: 'Role' }), 16 ] 17 18 const 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 53 import { DataTable } from '@/components/ui/data-table' 54 55 const 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.
1 const 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.
1 const keys = React.useMemo(() => sorted.map(r => `${(r as Row)[rowKey]}`), [sorted, rowKey]) 2 const allSelected = keys.length > 0 && keys.every(k => selected.has(k)) 3 4 React.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.
1 const [page, setPage] = useState(1) 2 const pageSize = 3 3 const pageData = users.slice((page - 1) * pageSize, page * pageSize) 4 const 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 />
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
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.