Skip to content
Data Display

TanStack Table — Component Library Guide

Headless UI for building powerful tables and datagrids. Sorting, filtering, pagination, column pinning, row selection, and virtualization.

★ Editor's Pick TypeScript MIT
2.9M weekly downloads
25.6k GitHub stars
~15kB bundle size
v8.21.2 latest version
tabledatagridsortingfilteringpaginationheadless

TL;DR: TanStack Table is a headless table engine - it handles sorting, filtering, pagination, and row selection logic but renders nothing. You write the HTML. Memoize your data array or it'll re-render on every tick. ~15kB. Official docs: tanstack.com/table.

What is TanStack Table?

TanStack Table is a headless table engine - it computes all the logic for sorting, filtering, pagination, column resizing, and row selection, but renders nothing itself. You write the HTML and CSS; the library provides the state and helpers.

This approach means the library works with any styling system (Tailwind, CSS-in-JS, MUI, shadcn) and any data source. It's the right choice when pre-built table components are too opinionated for your design system.

When to use it

Use TanStack Table when:

  • Your design system requires full control over table markup
  • You need features like column pinning, row expansion, or virtual scrolling
  • You're building a data-heavy admin interface or analytics dashboard

Use a pre-built table (MUI DataGrid, AG Grid Community) if you need Excel-like editing, enterprise features, or you don't mind the design lock-in.

Key Features

  • Headless - renders no HTML or CSS; you own the markup
  • Sorting - multi-column, custom sort functions, server-side support
  • Filtering - global filter, column-level filters, faceted filtering
  • Pagination - manual or automatic, with pageSize and pageIndex
  • Row selection - checkbox selection, single/multi select
  • Column features - pinning, resizing, visibility toggling, grouping
  • Virtualization - integrates with TanStack Virtual for large datasets

Installation

npm install @tanstack/react-table

Basic Usage

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
} from '@tanstack/react-table'
import { useState } from 'react'

interface User { id: string; name: string; email: string; role: string }

const columns: ColumnDef<User>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' },
  { accessorKey: 'role', header: 'Role' },
]

function UsersTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState<SortingState>([])

  const table = useReactTable({
    data,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  })

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map(group => (
          <tr key={group.id}>
            {group.headers.map(header => (
              <th
                key={header.id}
                onClick={header.column.getToggleSortingHandler()}
                style={{ cursor: 'pointer' }}
              >
                {flexRender(header.column.columnDef.header, header.getContext())}
                {{ asc: ' ^', desc: ' v' }[header.column.getIsSorted() as string] ?? ''}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id}>
            {row.getVisibleCells().map(cell => (
              <td key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

TypeScript Tips

Define column types precisely with ColumnDef<TData, TValue>:

// Custom cell renderer with typed row data
const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => {
      // row.original is typed as User
      const status = row.original.status
      return <StatusBadge status={status} />
    },
  },
]

Common Gotchas

  • Stable data reference - TanStack Table re-runs row models when data changes. Memoize your data array with useMemo to avoid infinite re-renders
  • Server-side operations - set manualSorting: true, manualFiltering: true, and manualPagination: true, then handle state changes yourself with API calls
  • flexRender is required - don't try to render columnDef.header directly; it can be a string, function, or component

What "headless" buys you and what it costs

Headless means the library hands you logic and zero markup. That sounds like extra work, and it is, at first. You write every <table>, <tr>, and <td> yourself. But the payoff is total control. Your table inherits your design system instead of fighting it. No overriding a grid library's CSS specificity wars, no theme that almost matches your brand.

The cost is real on day one and shrinks fast after. Once you've written your render layer once, you reuse it across every table in the app. Compare that to a pre-built grid where customizing the cell renderer means reading 40 pages of config docs. The honest tradeoff: pick headless when design control matters more than time-to-first-table. Pick a batteries-included grid when you need Excel-like editing yesterday.

Client-side versus server-side data

Small datasets live happily in the browser. Load a few thousand rows, hand them to TanStack Table, and let it sort, filter, and paginate in memory. Snappy and simple.

Cross into tens of thousands of rows or data you can't fully ship to the client, and you flip to manual mode. Set manualSorting, manualFiltering, and manualPagination to true. Now the table stops doing the work itself and instead reports state changes to you. You translate sorting and page state into query parameters, fetch the matching slice from your backend, and feed it back in. The table becomes a controller for your server query rather than the data engine. Most production admin panels end up here.

Row selection, expansion, and column control

Beyond the basics, the feature set is where this library earns its download count. Checkbox row selection tracks selected IDs for you, which you wire to bulk actions like delete or export. Row expansion renders detail panels or nested sub-rows under a parent, perfect for order lines under an order.

Column features round it out: pin a column so it stays put during horizontal scroll, let users resize columns by dragging the border, and toggle column visibility through a menu. Each feature is opt-in through a getXxxRowModel import, so your bundle only carries what you use. That tree-shaking discipline keeps the footprint reasonable even with many features enabled.

Scaling to huge tables

There's a ceiling on how many DOM rows a browser tolerates before scrolling stutters, usually a few thousand. TanStack Table doesn't solve rendering volume on its own - it computes logic, not DOM. For genuinely large tables you pair it with virtualization. The table instance gives you the full sorted and filtered row model; a virtualizer renders only the rows in the viewport.

The split is clean and worth remembering: the table owns sorting, filtering, and selection logic, while the virtualizer owns which DOM nodes exist. They share the same row index, so mapping between them is straightforward. This combination handles a hundred thousand rows without the page locking up.

Typing tables with generics

The generic useReactTable<TData> flows your row type through everything. Define ColumnDef<User>[] and row.original is typed as User inside every cell renderer. No casting, no guessing. This catches a real bug class early - reference a column key that doesn't exist on your type and the compiler stops you before runtime. For data-heavy apps where the shape changes often, that type safety pays for itself.

  • TanStack Virtual - pair with TanStack Table to virtualise rows when rendering thousands of records
  • TanStack Query - use useQuery to fetch and cache table data; TanStack Table handles presentation
  • React Resizable Panels - build IDE-style layouts with a resizable sidebar alongside your data table
  • TypeScript Generics Guide - useReactTable<TData>() is fully generic; understand generics to correctly type column definitions

Frequently Asked Questions

Does TanStack Table render a table element for me?

No. TanStack Table is headless - it computes state and helpers but renders nothing. You write the table HTML using standard elements like table, thead, tbody, tr, and td. This gives you full control over markup and styling.

How do I implement server-side sorting and filtering?

Set manualSorting, manualFiltering, and manualPagination to true in useReactTable. Then listen to onSortingChange and onPaginationChange to update your query parameters and fetch new data from the server.

Why does my table re-render on every state change?

TanStack Table re-runs row models whenever the data prop changes reference. Wrap your data array in useMemo to keep the reference stable between renders. The same applies to the columns definition array.

Can TanStack Table handle 100,000 rows?

For very large datasets, combine TanStack Table with TanStack Virtual to render only visible rows. TanStack Table handles sorting and filtering logic while TanStack Virtual handles the DOM rendering of only the visible subset.

What is the difference between TanStack Table and AG Grid?

AG Grid is a feature-complete, pre-rendered data grid with Excel-like editing and enterprise features. TanStack Table is a headless library that provides logic only. TanStack Table gives you full design control; AG Grid gives you out-of-the-box functionality.