Every React and Vue app eventually needs a table that does more than list rows: sortable columns, a search box, pagination, and a way to select multiple rows for a bulk action. Building that from scratch with plain HTML tables gets messy fast, and most charting libraries do not handle it well because a data grid is not a chart. TanStack Table, formerly known as React Table before it went framework-agnostic, is the headless library that both React and Vue teams now reach for, and its V9 line became officially stable in August 2026, according to TanStack, shipping with 10 framework adapters built on one shared core. This tutorial walks through building the same sortable, filterable, paginated data table in React 19.3 and Vue 3.5.42, side by side, so you can see exactly where the two implementations diverge and where they are nearly identical.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why TanStack Table Is the Right Choice for React and Vue Data Grids
TanStack Table is “headless,” meaning it manages state, sorting logic, filtering, and pagination math, but renders zero markup itself. You own every <td> and <tr>, which means the table looks exactly like your design system instead of fighting an opinionated grid component. That matters more in a React vs Vue comparison than it sounds, because both frameworks’ adapters wrap the exact same @tanstack/table-core package, which itself gained a new TanStack Store state-management layer in June 2026. According to the project’s GitHub repository, V9 supported 7 frameworks that same month, React, Preact, Solid, Vue, Angular, Svelte, and Lit, and had grown to 10 framework adapters by August 2026, all sitting on one shared engine, so the sorting comparator you write for React works unchanged in Vue.
That shared-core design is also why this comparison is more useful than most React vs Vue tutorials: you are not comparing two different libraries with two different APIs, you are comparing how each framework’s reactivity model (React’s re-render-and-diff vs Vue’s fine-grained reactivity through refs and computed properties) changes the code you write around the same table engine. TanStack Table V9 entered beta on June 7, 2026, and shipped v9.2.4 across every adapter on August 28, 2026, per the TanStack Table release notes; the project has since pushed a v9.2.6 patch release on October 4, 2026, though this tutorial continues to target 9.2.4 throughout since that is what most production apps have pinned.
This is distinct from a data-fetching or charting comparison. Fetching gets you the rows; a chart turns rows into a visual trend. A data table is about letting a user scan, sort, filter, and act on rows that are already in memory or paginated from a server, which is a different problem with different performance tradeoffs, especially once row counts climb into the thousands.
It also means this tutorial produces genuinely transferable knowledge. Once you understand how @tanstack/react-table wires sorting, filtering, and pagination state into React’s render cycle, reading the Vue equivalent is mostly a matter of translating useState into ref and remembering the reactive-getter pattern covered in Step 6. That is a much smaller mental leap than, say, comparing two unrelated charting libraries with entirely different APIs. For teams running both a React app and a Vue app, which is increasingly common after acquisitions or platform splits, standardizing on the same table engine across both codebases also means one set of sorting bugs to fix instead of two.
Prerequisites and Versions You’ll Need
Confirm your toolchain before you start. Mismatched versions are the single most common source of confusing type errors in this stack, since TanStack Table’s TypeScript generics changed meaningfully across major versions, though V9 specifically cut type instantiations by 62 to 86 percent by June 2026, according to TanStack, which noticeably eases the “Type instantiation is excessively deep” errors covered later in this guide.
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 24.21.0 LTS “Krypton” | Released Sept 7, 2026, current LTS per nodejs.org |
| React | 19.3.0 | Released Sept 9, 2026, adds View Transitions and Fragment Refs |
| Vue | 3.5.42 | Released Aug 27, 2026, Composition API baseline |
| @tanstack/react-table | 9.2.4 | Released Aug 28, 2026 |
| @tanstack/vue-table | 9.2.4 | Same release date, same core engine |
| @tanstack/react-virtual | 3.14.11 | For virtualizing large row counts, released Sept 7, 2026 |
| @tanstack/vue-virtual | 3.13.37 | Vue virtualization adapter, same release date |
| Vite | 8.3.0 | Released Sept 10, 2026, used to scaffold both projects |
| TypeScript | 7.0.2 or later | Released Aug 20, 2026; strongly recommended for column typing |
You will also need a code editor with TypeScript support, a terminal, and roughly 90 minutes if you follow every step in both frameworks. If you only care about one framework, budget 45 minutes and skip the other framework’s step.
Both React 19 and Vue 3 are under active support, according to lifecycle trackers at endoflife.date/react and endoflife.date/vue, which means there is no migration pressure forcing you off either version soon. That stability matters for a table implementation specifically, since a data grid tends to be one of the more heavily tested and heavily used components in any dashboard-style application, and churn in its underlying framework version is disruptive to QA cycles. Pin your package.json to the exact versions in the table above rather than using a caret range while you work through this tutorial, then loosen the version constraint once your table is stable and covered by tests.
Step 1: Scaffold Both Projects with Vite
Vite 8.3.0 is the fastest way to bootstrap either project with TypeScript pre-wired. Run these from wherever you keep your projects.
# React project
npm create vite@latest react-data-table -- --template react-ts
cd react-data-table
npm install
cd ..
# Vue project
npm create vite@latest vue-data-table -- --template vue-ts
cd vue-data-table
npm install
cd ..
Verify both dev servers boot before moving on: npm run dev in each directory should give you a local URL, typically http://localhost:5173 for the first one and 5174 for the second since Vite auto-increments the port when it finds one taken.
Step 2: Install TanStack Table and Supporting Packages
Each framework gets its matching adapter package plus the virtualization add-on you will use later in Step 10.
# Inside react-data-table
npm install @tanstack/[email protected] @tanstack/[email protected]
# Inside vue-data-table
npm install @tanstack/[email protected] @tanstack/[email protected]
Notice both install the same major and minor version number. That is intentional: TanStack ships all framework adapters from one monorepo release, so keeping the version numbers identical between your React and Vue projects avoids subtle behavioral drift between the two.
Step 3: Shape Your Data and Define Columns
Both frameworks consume the exact same column definition shape, since it comes from @tanstack/table-core. Create a shared type and a mock dataset you can paste into either project.
// types.ts — identical in both projects
export type Employee = {
id: number
name: string
department: string
role: string
salary: number
startDate: string
}
export const employees: Employee[] = [
{ id: 1, name: "Priya Nair", department: "Engineering", role: "Senior Engineer", salary: 142000, startDate: "2022-03-14" },
{ id: 2, name: "Marcus Lee", department: "Sales", role: "Account Executive", salary: 98000, startDate: "2023-07-01" },
{ id: 3, name: "Elena Vasquez", department: "Design", role: "Product Designer", salary: 115000, startDate: "2021-11-08" },
{ id: 4, name: "Tomás Ferreira", department: "Engineering", role: "Staff Engineer", salary: 168000, startDate: "2019-05-20" },
{ id: 5, name: "Aisha Rahman", department: "Marketing", role: "Growth Lead", salary: 108000, startDate: "2024-01-15" },
]
Column definitions describe how each field maps to a header and a cell. This file is nearly identical between React and Vue, which is the whole point of a headless, framework-agnostic core.
// columns.ts — shared logic, framework import differs
import { createColumnHelper } from "@tanstack/react-table" // or "@tanstack/vue-table"
import type { Employee } from "./types"
const columnHelper = createColumnHelper()
export const columns = [
columnHelper.accessor("name", { header: "Name" }),
columnHelper.accessor("department", { header: "Department" }),
columnHelper.accessor("role", { header: "Role" }),
columnHelper.accessor("salary", {
header: "Salary",
cell: (info) => `$${info.getValue().toLocaleString()}`,
}),
columnHelper.accessor("startDate", { header: "Start Date" }),
]
The only line that changes between frameworks is the import path for createColumnHelper. Everything else, including the cell formatter, is portable.
Generating a Larger Dataset for Realistic Testing
Five rows will not tell you anything about sorting, filtering, or pagination behavior. Before moving on, generate a few hundred rows so later steps have something meaningful to act on.
// generate-mock-data.ts — run once, paste the output into employees array
const departments = ["Engineering", "Sales", "Design", "Marketing", "Support"]
const roles = ["Associate", "Senior", "Staff", "Lead", "Manager"]
function randomEmployee(id: number): Employee {
const dept = departments[Math.floor(Math.random() * departments.length)]
const role = roles[Math.floor(Math.random() * roles.length)]
return {
id,
name: `Employee ${id}`,
department: dept,
role: `${role} ${dept.slice(0, 3)}`,
salary: 60000 + Math.floor(Math.random() * 120000),
startDate: new Date(2018 + Math.floor(Math.random() * 8), Math.floor(Math.random() * 12), 1)
.toISOString()
.slice(0, 10),
}
}
export const largeDataset: Employee[] = Array.from({ length: 500 }, (_, i) => randomEmployee(i + 1))
Swap the employees import in your table component for largeDataset once you reach Step 8 (Pagination); that is where a 5-row array stops being useful and a 500-row array starts showing you whether your page-count math is actually correct.
Step 4: Build the Base React Table
React’s useReactTable hook takes your data and columns and returns a table instance. Because React re-renders on state change, you pass state and its setter explicitly, and TanStack Table reads from that state on every render.
// DataTable.tsx
import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table"
import { columns } from "./columns"
import { employees } from "./types"
export function DataTable() {
const table = useReactTable({
data: employees,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
{flexRender(header.column.columnDef.header, header.getContext())}
))}
))}
{table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
))}
)
}
Run npm run dev and you should see a plain five-row, five-column table render with no styling. That confirms the wiring works before you add interactivity.
Step 5: Build the Base Vue Table
Vue’s useVueTable composable looks almost identical on the surface, but under the hood it hooks into Vue’s reactivity system instead of React’s render cycle, so the table instance itself is reactive rather than being recomputed on every parent re-render.
<!-- DataTable.vue -->
<script setup lang="ts">
import { useVueTable, getCoreRowModel, FlexRender } from "@tanstack/vue-table"
import { columns } from "./columns"
import { employees } from "./types"
const table = useVueTable({
data: employees,
columns,
getCoreRowModel: getCoreRowModel(),
})
</script>
<template>
<table>
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th v-for="header in headerGroup.headers" :key="header.id">
<FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
</tbody>
</table>
</template>
Vue uses a FlexRender component instead of React’s flexRender function, since Vue templates render components declaratively rather than calling a function inline. Functionally, the two do the same job: turning a column’s header or cell definition, which might be a plain string or a render function, into actual markup.
Step 6: Add Column Sorting
Sorting is where the state-management difference between the two frameworks becomes visible. In React, you manage a sorting state array with useState and pass both the value and setter into the table config. In Vue, you use a ref and pass a getter function, since Vue’s reactivity needs the table to read the current value rather than a snapshot.
// React: add to DataTable.tsx
import { useState } from "react"
import { getSortedRowModel, type SortingState } from "@tanstack/react-table"
const [sorting, setSorting] = useState([])
const table = useReactTable({
data: employees,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
// In the , make the header clickable:
//
// {flexRender(header.column.columnDef.header, header.getContext())}
// {{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? null}
//
<!-- Vue: add to DataTable.vue script setup -->
<script setup lang="ts">
import { ref } from "vue"
import { getSortedRowModel, type SortingState } from "@tanstack/vue-table"
const sorting = ref<SortingState>([])
const table = useVueTable({
data: employees,
columns,
state: { get sorting() { return sorting.value } },
onSortingChange: (updater) => {
sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
</script>
<!-- In the template, make the header clickable: -->
<!-- <th @click="header.column.getToggleSortingHandler()?.($event)"> -->
That get sorting() getter pattern in Vue trips up almost every React developer the first time they touch @tanstack/vue-table. It exists because Vue’s state object needs to stay reactive on every access, and a plain sorting: sorting.value would only capture the value once, at setup time, instead of tracking future changes.
Step 7: Add Filtering and Global Search
Global filtering searches across every column at once, which is what most users expect from a search box above a table. Add getFilteredRowModel and a text input bound to a globalFilter state value.
// React
import { getFilteredRowModel } from "@tanstack/react-table"
const [globalFilter, setGlobalFilter] = useState("")
const table = useReactTable({
// ...previous config
state: { sorting, globalFilter },
onGlobalFilterChange: setGlobalFilter,
getFilteredRowModel: getFilteredRowModel(),
})
// setGlobalFilter(e.target.value)} />
The Vue version follows the same getter pattern from Step 6. Bind the input with v-model="globalFilter" and pass globalFilter.value through the same reactive getter you used for sorting. Column-specific filters (for example, a dropdown that filters only the Department column) use column.getFilterValue() and column.setFilterValue() in both frameworks, with the same reactive-getter caveat on the Vue side.
Step 8: Add Pagination
For datasets under a few thousand rows, client-side pagination with getPaginationRowModel is simpler than a server round trip. Both frameworks expose the same navigation methods: table.nextPage(), table.previousPage(), table.getCanNextPage(), and table.setPageSize().
// Works in both React and Vue configs (adjust import path per framework)
import { getPaginationRowModel } from "@tanstack/react-table"
const table = useReactTable({
// ...previous config
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
})
// Render controls with:
// table.getState().pagination.pageIndex + 1
// table.getPageCount()
// disabled={!table.getCanPreviousPage()}
Output at this point, with 500 mock rows and a page size of 10, should show “Page 1 of 50” and a functioning Previous/Next pair. If the count never changes when you click Next, double-check that getPaginationRowModel was actually added to the table config; it is the single most common miss in this step.
Step 9: Add Row Selection and Bulk Actions
Row selection needs a dedicated checkbox column plus a rowSelection state object keyed by row ID. This is also where you wire up a bulk action bar, for example, a “Delete 3 selected” button that only appears once at least one row is checked.
// Add as the first entry in your columns array
columnHelper.display({
id: "select",
header: ({ table }) => (
),
cell: ({ row }) => (
),
})
Read table.getSelectedRowModel().rows to get the currently checked rows in either framework, then map that to whatever bulk action you need, such as a DELETE request to your API with the selected IDs. In Vue, remember the same reactive-state-getter requirement applies to rowSelection as it did to sorting and globalFilter.
Step 10: Virtualize Large Datasets with TanStack Virtual
Client-side pagination works fine up to a few thousand rows, but once you are rendering tens of thousands of rows at once (for example, in an “infinite scroll” table instead of paged navigation), rendering every row’s DOM node will freeze the browser. TanStack Virtual, installed back in Step 2, only renders the rows currently visible in the scroll viewport, plus a small overscan buffer.
// React: wrap your table body in a scroll container
import { useVirtualizer } from "@tanstack/react-virtual"
import { useRef } from "react"
const parentRef = useRef(null)
const rows = table.getRowModel().rows
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // row height in px
overscan: 10,
})
//
//
// {rowVirtualizer.getVirtualItems().map((virtualRow) => { ...render rows[virtualRow.index]... })}
//
//
@tanstack/vue-virtual exposes the same useVirtualizer composable with the same options object; the only change is swapping the React ref for a Vue ref template ref and wrapping getScrollElement in () => parentRef.value. Test this with a 10,000-row mock dataset: without virtualization, initial render will noticeably stutter on mid-range hardware; with it, only 15 to 20 DOM rows exist at any time regardless of total row count.
Step 11: Handle Loading, Empty, and Error States
A production table needs to handle three states beyond “data loaded successfully”: the initial fetch, an empty result set, and a failed request. Skipping these is one of the most common gaps reviewers flag in table PRs.
- Loading: render skeleton rows matching your column count, not a spinner replacing the whole table, so the layout does not jump once data arrives.
- Empty: check
table.getRowModel().rows.length === 0after filters are applied, and show a message that distinguishes “no data at all” from “no rows match your filter.” - Error: keep the last successfully loaded data visible with a dismissible error banner rather than clearing the table, which avoids punishing the user for a transient network blip.
Both frameworks handle this identically at the state level: track a status value (“idle” | “loading” | “error” | “success”) outside the table config and conditionally render around the <table> element based on it. TanStack Table itself has no opinion on loading states since it only manages already-available data.
Step 12: Style the Table and Prepare for Production
Because TanStack Table renders no markup of its own, styling is entirely yours to own, which also means no CSS override fights with a third-party grid component. A sticky header, alternating row colors, and a sortable-column indicator cover most real-world table requirements.
/* table.css — works with either framework's markup */
thead th {
position: sticky;
top: 0;
background: #ffffff;
cursor: pointer;
user-select: none;
border-bottom: 2px solid #e5e7eb;
}
tbody tr:nth-child(even) { background: #f9fafb; }
tbody tr:hover { background: #eef2ff; }
th[aria-sort="ascending"]::after { content: " ▲"; }
th[aria-sort="descending"]::after { content: " ▼"; }
Before shipping, run a production build in both projects (npm run build) and confirm no TypeScript errors surface. Column typing is where React and Vue projects most often diverge in build output, since Vue’s <script setup> macros compile differently than React’s plain function components, and a loosely typed ColumnDef array can pass in dev mode but fail a strict production build.
React vs Vue TanStack Table: Bundle Size and Performance
Since both adapters wrap the same @tanstack/table-core engine, the sorting, filtering, and pagination math run identically fast in either framework. The differences that matter in production come from each framework’s rendering model, not the table library itself.
| Factor | React 19.3 + @tanstack/react-table | Vue 3.5.42 + @tanstack/vue-table |
|---|---|---|
| Re-render trigger | Whole component re-renders on state change; React reconciles the virtual DOM diff | Fine-grained reactivity updates only the DOM nodes tied to changed refs |
| State wiring | Direct value + setter pairs (useState) | Reactive getters required in the state object (see Step 6) |
| Cell rendering helper | flexRender() function call | <FlexRender> component |
| Large-list re-render cost | Higher without useMemo/virtualization on frequent state changes | Lower by default due to targeted reactivity, still benefits from virtualization at scale |
| Adapter package | @tanstack/[email protected] | @tanstack/[email protected] |
| Virtualization adapter | @tanstack/[email protected] | @tanstack/[email protected] |
| TypeScript ergonomics | Column generics infer directly from function components | Requires explicit typing inside <script setup lang="ts"> generics |
In practice, the gap that developers actually feel is the reactive-getter requirement in Vue rather than any raw speed difference: the table-core sorting and filtering algorithms are framework-agnostic, so a 500-row sort takes the same number of CPU cycles regardless of which framework called it. Where React apps tend to lose time is unnecessary re-renders of the entire table when an unrelated piece of parent state changes, which React.memo around the table component and stable column/data references usually fix.
TanStack Table vs Fully Styled Grid Alternatives
TanStack Table is not the only option for either framework, and it is worth knowing when a batteries-included grid component is the better tradeoff. AG Grid ships its own rendering, its own theming system, and enterprise features like row grouping and Excel export out of the box, at the cost of a much larger bundle and a licensing fee for the enterprise feature set. MUI X Data Grid is a strong choice specifically for React teams already standardized on Material UI, since it inherits the same design tokens and theming. On the Vue side, Vuetify’s data table component and PrimeVue’s DataTable serve the same batteries-included role, each tied to their respective component libraries.
The tradeoff is consistent across all of them: a pre-styled grid gets you to a working table faster if your design already matches its look, but you inherit its CSS, its accessibility implementation, and its release cadence for new row models. TanStack Table takes longer to reach the same feature set because you build the markup yourself, but the resulting table matches your design system exactly and carries no extra CSS payload beyond what you write. Teams with a strict design system, or teams that need the same table behavior across more than one framework, tend to land on TanStack Table for that reason. Teams that want row grouping, Excel export, and enterprise support without building it themselves often find AG Grid or MUI X Data Grid a faster path to production.
Common Pitfalls When Building Data Tables
These mistakes account for the majority of confused GitHub issues filed against TanStack Table each month.
- Redefining the columns array on every render. In React, declaring
columnsinline inside the component body creates a brand-new array reference every render, which defeats memoization and can trigger unnecessary table recalculation. Define columns outside the component or wrap them inuseMemo. - Forgetting the reactive getter in Vue’s state object. Writing
state: { sorting: sorting.value }instead ofstate: { get sorting() { return sorting.value } }silently breaks reactivity; the table will render the initial sort state once and never update again. - Mutating row data directly. Both frameworks expect immutable updates. Directly pushing to the data array or mutating a cell value in place will not trigger a re-render in React and can produce inconsistent reactivity tracking in Vue.
- Skipping virtualization until it is too late. Teams often ship a table that works fine in a demo with 20 rows, then discover it locks the main thread once real data hits 5,000+ rows in production. Decide upfront whether you need
@tanstack/react-virtualor@tanstack/vue-virtualbased on expected row counts, not after a support ticket. - Using array index as the row key. When rows can be sorted, filtered, or reordered, using the array index as the React
keyor Vue:keycauses cell state (like an open dropdown or an input’s focus) to attach to the wrong row after a re-sort. Always key by a stable row ID viarow.id.
Troubleshooting Guide
Working through these eight issues covers the overwhelming majority of setup problems reported for this stack.
- Sorting arrows never change on click: confirm
getSortedRowModel: getSortedRowModel()is present in the table config and that the header’s click handler is wired toheader.column.getToggleSortingHandler(), not a custom onClick that forgets to call it. - Vue table state appears frozen after the first update: this is almost always the missing reactive-getter pattern from Step 6; wrap every stateful key in
statewith a getter function. - TypeScript error “Type instantiation is excessively deep”: usually caused by an overly complex or circular type in your
Employee-equivalent data shape; simplify nested generics or add an explicitColumnDef<YourType>[]annotation. - Pagination controls show “Page 1 of 0”: your data array is empty at the moment the table initializes; guard the pagination UI behind a loaded-data check, or initialize with an empty array and a loading flag rather than
undefined. - Global filter matches nothing even though the text is visible on screen: the default filter function only searches accessor-mapped columns; a
displaycolumn (like your row-selection checkbox column) is excluded by design, and a custom cell formatter’s output text is not what gets searched, the raw accessor value is. - Virtualized rows flicker or jump while scrolling: your
estimateSizevalue does not match actual rendered row height; measure the real row height in devtools and update the estimate, or usemeasureElementfor dynamic-height rows. - “Cannot read properties of undefined (reading ‘getContext’)” in Vue: this typically means
columnswas passed as a plain array literal withoutmarkRaw, causing Vue to deep-proxy the column definitions and break internal reference checks; wrap static column arrays inmarkRaw(). - Row selection checkbox state resets after sorting: confirm you’re keying by
row.idand not array index, and thatrowSelectionstate persists independently ofgetSortedRowModel‘s output, since selection should track the underlying row identity, not its position.
Advanced Tips for Production-Grade Tables
Once the basic table works in both frameworks, a few patterns separate a demo from something that survives real traffic and real data volumes.
For datasets too large to load client-side entirely, switch getPaginationRowModel for manual pagination: set manualPagination: true, pass pageCount from your API’s total-count response, and fetch a new page of rows whenever pagination.pageIndex changes. The same applies to sorting and filtering with manualSorting: true and manualFiltering: true, at which point your backend does the heavy lifting and TanStack Table just reflects whatever page of data your API returned. This is the standard approach once a table needs to represent hundreds of thousands of rows that cannot reasonably live in browser memory.
Column resizing and column visibility toggles use the same state-plus-onXChange pattern as sorting, so once you understand the reactive-getter requirement in Vue, adding columnVisibility or columnSizing state is a copy-paste of the sorting wiring with different key names. For persisted preferences (a user’s saved column order or hidden columns), serialize that state object to localStorage on change and rehydrate it on mount.
If you need drag-to-reorder columns or rows on top of this table, pair TanStack Table with a dedicated drag library rather than hand-rolling pointer events; TanStack Table only tracks column order state, it does not provide drag interactions itself. Finally, for tables rendered inside a React Server Component tree under React 19.3, keep the interactive table itself in a client component (marked with "use client") since sorting and filtering both require client-side state that a server component cannot hold.
Accessibility is another area TanStack Table leaves entirely to you, which is easy to forget once the table visually works. Add scope="col" to header cells, set aria-sort (“ascending”, “descending”, or “none”) on sortable headers based on header.column.getIsSorted(), and make sure the sort toggle is reachable by keyboard, not just a click handler on a <th>. Wrapping the sortable header content in a real <button> element instead of relying on a div with an onClick handler solves keyboard access and screen-reader semantics at the same time, in both frameworks.
Output Example: What a Finished Table Looks Like
After completing all twelve steps in either framework, loading the page with the five-row mock dataset from Step 3 and a page size of 10 should render a table with a sticky header row reading Name, Department, Role, Salary, Start Date, each clickable and showing a ▲ or ▼ arrow once sorted. Typing “Engineering” into the search box should immediately narrow the visible rows to Priya Nair and Tomás Ferreira. Checking both of their row checkboxes should reveal a “2 selected” bulk-action bar. With the 10,000-row virtualized version from Step 10, the browser’s DOM inspector should show roughly 15 to 20 <tr> elements at any scroll position, never the full row count, confirming virtualization is active.
Complete Working Project Recap
By the end of this tutorial you have two working projects, each with its own types.ts, near-identical columns.ts, a DataTable component wired for sorting, filtering, pagination, row selection, and virtualization, plus a shared table.css stylesheet. The React project uses useState and useReactTable; the Vue project uses ref, the reactive-getter pattern, and useVueTable. Both sit on the identical @tanstack/[email protected] engine, which is why the sorting comparator, filter function, and pagination math behave identically between them even though the surrounding framework code differs.
From here, the natural next steps are swapping the mock employees array for a real API call, adding column-level filter UI beyond the global search box, and deciding whether manual (server-side) or automatic (client-side) pagination fits your actual row counts. The official TanStack Table documentation covers additional row models like grouping and expanding if your table needs to go beyond flat rows.
Frequently Asked Questions
Is TanStack Table free and open source?
Yes. TanStack Table is MIT-licensed and free for commercial use, published on GitHub under the TanStack organization alongside TanStack Query, Router, and Virtual. Adoption reflects that: the project logged 28,477 GitHub stars as of October 7, 2026, and the package was pulling in 30,540,402 weekly downloads as of October 2026, according to TanStack and GitHub.
Can I use TanStack Table with plain JavaScript instead of TypeScript?
Yes, TypeScript is optional. You lose compile-time column type checking, but every API shown in this tutorial works the same in a .jsx or Vue Options API-free .vue file written in plain JavaScript.
Do React and Vue versions of TanStack Table perform differently at scale?
The core sorting, filtering, and pagination logic runs identically since both adapters call the same @tanstack/table-core functions. The practical difference comes from each framework’s rendering model: Vue’s fine-grained reactivity tends to update fewer DOM nodes per state change by default, while React relies on techniques like useMemo and virtualization to avoid unnecessary re-renders at large row counts.
When should I add virtualization instead of just using pagination?
Use pagination when users expect discrete pages (common in admin dashboards and reports). Use virtualization when you want a continuous scroll experience, such as an infinite-scrolling activity feed or log viewer, or when a single page would otherwise need to render thousands of rows at once.
Does TanStack Table work with Nuxt or Next.js server components?
Yes, but the interactive table itself needs to run client-side since sorting, filtering, and selection all depend on browser state. In Next.js App Router, mark the table component with "use client". In Nuxt, the table composable works inside any component; just be mindful of hydration if you server-render initial sorted or filtered state.
What is the biggest gotcha when migrating a table from React to Vue or vice versa?
The reactive-getter requirement covered in Step 6. Developers moving from React instinctively write state: { sorting: sorting.value } in Vue, which compiles fine but silently breaks reactivity after the first render.
Can I style TanStack Table with Tailwind CSS instead of plain CSS?
Yes. Since the library renders no markup of its own, you can apply Tailwind utility classes directly to your <table>, <th>, and <td> elements exactly as you would with any hand-built table, in either framework.
How many rows can a client-side table handle before I need server-side pagination?
There’s no hard limit, but as a practical guideline, once a dataset exceeds roughly 5,000 to 10,000 rows, initial load time and memory usage in the browser start to degrade noticeably. At that point, switch to manual pagination, sorting, and filtering so your API only ever sends the current page’s rows.





