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
| 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
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.
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.