Two years ago you built a staff roster. Data lives in Supabase, you threw Material-UI's DataGrid at it, added filtering and sorting, shipped it. Eighteen months later the designer wants the table rows to be checkboxes that feed a bulk-action toolbar. The pagination footers need custom styling. Mobile needs horizontal scroll with sticky left columns. You're three hours into MUI DataGrid prop-drilling, reading a 40-line table config, and realizing you're fighting the component philosophy. Your colleague ships a TanStack Table table in an afternoon — same data, custom markup, filtering and sorting built-in, infinite scroll working. You ask why nobody told you.
This is the data table decision tree most SaaS teams get wrong. AG Grid is legendary in enterprise finance (banks, trading floors, Bloomberg terminals). It owns pivot tables, real-time cell updates, server-side aggregation, Excel copy-paste. But enterprise features come with enterprise licensing and 500KB bundle hits. Material-UI DataGrid, AG Grid Community, Mantine Table — all component-first. You get a table component and drill props. TanStack Table landed in 2020 as "headless table" — state management without UI. You own the markup, TanStack Table owns filtering, sorting, pagination, grouping, virtual scrolling. Separation of concerns, builder's freedom, bundle size under 20KB. Once you taste it, every other table library feels handcuffed.
Three Philosophies, Three Problems They Solve
AG Grid: Enterprise grade, ships everything. You import AgGridReact, mount a component, pass config, get a fully-featured table. Cell editors, column resizing, server-side filtering, grouping by 5 columns, export to Excel, copy-paste, keyboard shortcuts. Bundle: 500KB gzip for Community, more for Enterprise. License: Community is free (with attribution), Pro/Enterprise are paid. Philosophy: "We built the table so you don't have to." Best if: your data is complex (finance, real estate, logistics) or you need Excel parity or master-detail drilling with server-side aggregation. Worst if: you have a simple SaaS dashboard, a mobile app, or designers who own the component markup.
Material-UI / Mantine DataGrid: Component-first, unopinionated styling. DataGrid is a table component with theming props, column definitions, and event handlers. You customize via props (sx in MUI, className chains in Mantine). Bundle: 150KB+ (includes the UI framework). Philosophy: "We built a base table, theme it yourself." Best if: you're already on Material-UI or Mantine and want consistency. Worst if: your designer has specific row styling or you're building a custom vertical-scroll infinite-load table — you're fighting the component mental model.
TanStack Table (React Table): Headless state management. You import useReactTable, pass columns and data, get back sorting, filtering, pagination, row selection, virtual scrolling. No UI — you write the JSX (<table>, <thead>, <tr>, etc.). Bundle: 18KB gzip. License: MIT. Philosophy: "We handle the logic, you own the markup." Best if: you need fine-grained control, custom styling, mobile-responsive tables, or multi-framework support (TanStack Table works in Vue, Svelte, Solid). Worst if: you want a drag-drop table builder or need master-detail drilling (you'll write it). This is what Velocity X ships for staff rosters, location sorting, analytics dashboards.
The Comparison Table
| Dimension | AG Grid | MUI DataGrid | TanStack Table |
|---|---|---|---|
| Bundle | 500KB gzip | 150KB gzip | 18KB gzip |
| Learning Curve | Steep (250+ props) | Moderate (component-first) | Low (hooks + JSX) |
| Styling Freedom | CSS classes (theme) | sx prop / className | 100% (you own markup) |
| Virtual Scroll | Native | Via plugin | Native (@tanstack/react-virtual) |
| Filtering | Server-side or client | Client-side | Agnostic (you choose) |
| License / Cost | Community free / Pro paid | Free (MIT) | Free (MIT) |
| Pivot / Master-Detail | Built-in | No | You build it |
| Best For | Enterprise, Excel parity | MUI ecosystem | SaaS dashboards, custom |
Minimal TanStack Table Setup
Here's a filterable, sortable staff roster with sorting UI and virtual scroll. No component props, pure JSX control:
import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
{ accessorKey: 'status', header: 'Status' },
];
export function StaffRoster({ data }) {
const [sorting, setSorting] = useState([]);
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const { rows } = table.getRowModel();
return (
<div className="space-y-4">
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search staff..."
className="w-full px-3 py-2 border rounded"
/>
<table className="w-full border-collapse border border-slate-300">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id} className="bg-slate-50">
{headerGroup.headers.map(header => (
<th
key={header.id}
className="px-4 py-2 text-left cursor-pointer hover:bg-slate-100"
onClick={() => header.column.toggleSorting()}
>
{header.isPlaceholder
? null
: (
<>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className="ml-2">
{header.column.getIsSorted() === 'desc' ? '↓' : '↑'}
</span>
)}
</>
)
}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} className="border-t hover:bg-slate-50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-4 py-2 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
That's a production table: filter input synced to all columns, clickable headers for sorting, visual sort indicators, hover states. You own the markup so you can add row checkboxes, link names to detail pages, apply Tailwind utilities per cell, or swap to <div> grid for mobile. TanStack Table doesn't care — it just manages state.
Four Production Patterns
1. Row Selection + Bulk Actions
Select checkboxes and trigger a toolbar with delete, archive, export buttons:
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
...config,
state: { rowSelection, ...state },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowCanBeSelected: (row) => row.original.status !== 'archived',
});
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
return (
<div>
{selectedCount > 0 && (
<div className="flex gap-2 mb-4 p-3 bg-blue-50 rounded">
<span>{selectedCount} selected</span>
<button onClick={() => deleteSelected()}>Delete</button>
<button onClick={() => exportSelected()}>Export CSV</button>
</div>
)}
{/* table markup */}
</div>
);
// In your table header:
<th>
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={(e) => table.toggleAllRowsSelected(e.target.checked)}
/>
</th>
// In your table body:
<td>
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={(e) => row.toggleSelected(e.target.checked)}
/>
</td>
Select a few rows, the toolbar appears. Shift-click to select a range, Cmd-click to toggle. All row selection logic is built-in; you just render checkboxes and wire the handlers. Mobile? Same table, smaller cells, checkbox on the left stays sticky. No prop drilling through layers of configuration.
2. Virtual Scrolling for 50K Rows
Staff rosters can grow large. Virtual scroll only renders visible rows:
import { useVirtualizer } from '@tanstack/react-virtual';
const { rows } = table.getRowModel();
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<table style={{ height: totalSize }}>
<tbody>
{virtualRows.map(virtualRow => (
<tr
key={virtualRow.key}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* render row */}
</tr>
))}
</tbody>
</table>
</div>
);
50K rows in the database. Only 15–20 render at a time. Scroll is buttery even on mobile. Sorting and filtering work on the full dataset instantly — TanStack Table processes it in memory (or you wire server-side filtering for truly massive datasets).
3. Sticky Header + Mobile Horizontal Scroll
Desktop: sticky header stays visible when scrolling. Mobile: left columns sticky, table scrolls horizontally:
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="sticky top-0 bg-white dark:bg-slate-900 z-10">
{/* headers */}
</thead>
<tbody>
{/* rows */}
</tbody>
</table>
</div>
On mobile, wrap the table in an overflow-x div and your content scrolls. Add sticky left-0 to the first cell in each row and the name column stays visible while other columns scroll away. CSS Grid table (display: grid?) works too. TanStack Table is indifferent — markup is yours.
4. Column Visibility Toggle
Users pick which columns to see — email, role, or status:
const [columnVisibility, setColumnVisibility] = useState({
email: true,
role: true,
status: true,
});
const table = useReactTable({
...config,
state: { columnVisibility, ...state },
onColumnVisibilityChange: setColumnVisibility,
});
return (
<div className="space-y-4">
<div className="flex gap-2">
{table.getAllLeafColumns().map(column => (
<label key={column.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={(e) => column.toggleVisibility(e.target.checked)}
/>
{column.columnDef.header}
</label>
))}
</div>
{/* table markup */}
</div>
);
Check or uncheck columns and the table reflows. No data reload, no API call, instant responsive table. Store the visibility in localStorage if the user wants their preference saved across sessions.
Six FAQs
Is TanStack Table production-ready?
Yes. It powers thousands of SaaS dashboards (Vercel, Netlify, Supabase, Plaid, Stripe). The API is stable, the team is responsive, and there's no "v5 breaking everything" risk — major versions happen ~2–3 years apart. Rebuild Relief's staff tools and Staff Operations Dashboard analytics both use TanStack Table. Ship with confidence.
What about AG Grid Community vs Pro? Should I pay?
AG Grid Community is free with attribution. Pro adds server-side filtering, real-time cell updates, Excel export, and paid support. If you need those features (enterprise finance, real-time trading), the cost is justified. But for SaaS dashboards (staff, invoices, analytics), Community is a trap — you're paying for features you don't use and fighting the licensing. TanStack Table is free, MIT, no strings. Export to CSV with two lines of code.
How do I integrate TanStack Table with a server-side API?
TanStack Table is client-side state management. When you filter or sort, dispatch an API call with the new filter/sort params, update state, table re-renders. For pagination, you can use manual pagination (query params: ?page=2&limit=20) or infinite scroll (append rows to state as user scrolls). No special TanStack Table magic — just React state + fetch.
Can I use TanStack Table in Next.js with Server Components?
No. TanStack Table requires 'use client' and React hooks (useState, useCallback). Use it in a Client Component child, pass server-fetched data as a prop. The server loads the data, the client renders the interactive table. Common pattern: <StaffRosterPage> (server) loads roster from DB, passes it to <StaffTable> (client) which handles filtering/sorting.
Does TanStack Table work with TypeScript?
Yes. Full TypeScript support. Define your row shape as a type, pass it to useReactTable<YourRowType>, and you get autocomplete on all accessors and column definitions. Schema validation (Zod) before rendering makes this rock-solid.
What if I need a date picker in a table cell?
TanStack Table is state management. Cell content is JSX. Render a date picker (react-datepicker, radix Calendar, your own) inside the cell component. When the user picks a date, update row state, and TanStack Table re-renders. No special integration needed — markup is yours.
The Bottom Line
Pick your table library based on your constraints, not your ambitions. AG Grid owns enterprise: pivot tables, master-detail, real-time cells, Excel parity, if you pay the license. AG Grid Community is free but binds you to their component philosophy. Material-UI DataGrid is a solid middle ground if you're already on MUI. But TanStack Table is the fastest path to a custom, lightweight, performant table: headless state management, you own the markup, MIT license, 18KB bundle, virtual scroll out of the box. Pair it with custom table builds where your data interaction needs fine-grained control, and you've got a system that scales from day 1. Build the table in a day. Ship filters, sorting, and multi-select in an afternoon. Spend the week building the workflows around it instead.