React TanStack Table: Advanced Data Grid Guide (2026)

TanStack Table v8 (formerly React Table) is the headless data grid library for React. "Headless" means it provides all the logic — sorting, filtering, pagination, row selection, column resizing — but zero UI. You own every pixel of the rendered table. This guide builds a full-featured data grid with TypeScript from scratch.

Setup and Column Definitions

npm install @tanstack/react-table
import {
  createColumnHelper,
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
} from '@tanstack/react-table'

// Data types
interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user' | 'viewer'
  status: 'active' | 'inactive'
  createdAt: string
  revenue: number
}

// Column helper provides type-safe column definitions
const columnHelper = createColumnHelper<User>()

const columns = [
  // Accessor column — maps to a data key
  columnHelper.accessor('name', {
    header: 'Name',
    cell: info => (
      <div className="flex items-center gap-3">
        <div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center text-white text-sm">
          {info.getValue()[0]}
        </div>
        <span>{info.getValue()}</span>
      </div>
    ),
    enableSorting: true,
  }),

  columnHelper.accessor('email', {
    header: 'Email',
    cell: info => <span className="text-gray-400">{info.getValue()}</span>,
  }),

  columnHelper.accessor('role', {
    header: 'Role',
    cell: info => {
      const role = info.getValue()
      const colors = { admin: '#6366f1', user: '#22d3ee', viewer: '#64748b' }
      return (
        <span style={{
          background: `${colors[role]}20`,
          color: colors[role],
          border: `1px solid ${colors[role]}40`,
          borderRadius: 50,
          padding: '2px 10px',
          fontSize: 12,
          fontWeight: 600,
        }}>
          {role}
        </span>
      )
    },
    filterFn: 'equals',
  }),

  columnHelper.accessor('revenue', {
    header: 'Revenue',
    cell: info => `$${info.getValue().toLocaleString()}`,
    sortingFn: 'basic',
  }),

  // Display column — no data key, for actions
  columnHelper.display({
    id: 'actions',
    header: 'Actions',
    cell: ({ row }) => (
      <div className="flex gap-2">
        <button onClick={() => editUser(row.original)}>Edit</button>
        <button onClick={() => deleteUser(row.original.id)}>Delete</button>
      </div>
    ),
  }),
]

Basic Table Rendering

function DataTable({ data }: { data: User[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div style={{ overflowX: 'auto' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          {table.getHeaderGroups().map(headerGroup => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map(header => (
                <th
                  key={header.id}
                  style={{
                    padding: '12px 16px',
                    textAlign: 'left',
                    color: '#64748b',
                    fontSize: 12,
                    fontWeight: 600,
                    textTransform: 'uppercase',
                    letterSpacing: '0.05em',
                    borderBottom: '1px solid rgba(99,102,241,0.2)',
                  }}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(header.column.columnDef.header, header.getContext())}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map(row => (
            <tr
              key={row.id}
              style={{ borderBottom: '1px solid rgba(99,102,241,0.1)' }}
              onMouseEnter={e => (e.currentTarget.style.background = 'rgba(99,102,241,0.05)')}
              onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
            >
              {row.getVisibleCells().map(cell => (
                <td key={cell.id} style={{ padding: '12px 16px', color: '#e2e8f0' }}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Sorting

import { useState } from 'react'
import type { SortingState } from '@tanstack/react-table'

function SortableTable({ 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(headerGroup => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map(header => (
              <th
                key={header.id}
                style={{ cursor: header.column.getCanSort() ? 'pointer' : 'default' }}
                onClick={header.column.getToggleSortingHandler()}
              >
                {flexRender(header.column.columnDef.header, header.getContext())}
                {/* Sort indicator */}
                {header.column.getIsSorted() === 'asc' && ' ↑'}
                {header.column.getIsSorted() === 'desc' && ' ↓'}
                {!header.column.getIsSorted() && header.column.getCanSort() && ' ↕'}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>{/* same as before */}</tbody>
    </table>
  )
}

Filtering

import type { ColumnFiltersState } from '@tanstack/react-table'

function FilterableTable({ data }: { data: User[] }) {
  const [globalFilter, setGlobalFilter] = useState('')
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

  const table = useReactTable({
    data,
    columns,
    state: { globalFilter, columnFilters },
    onGlobalFilterChange: setGlobalFilter,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    globalFilterFn: 'includesString',
  })

  return (
    <div>
      {/* Global search */}
      <input
        value={globalFilter}
        onChange={e => setGlobalFilter(e.target.value)}
        placeholder="Search all columns..."
        className="mb-4 w-full p-2 border rounded"
      />

      {/* Per-column filter */}
      <select
        onChange={e => table.getColumn('role')?.setFilterValue(e.target.value || undefined)}
        className="mb-4 p-2 border rounded"
      >
        <option value="">All roles</option>
        <option value="admin">Admin</option>
        <option value="user">User</option>
        <option value="viewer">Viewer</option>
      </select>

      <p className="text-sm text-gray-500 mb-2">
        {table.getFilteredRowModel().rows.length} of {data.length} rows
      </p>
      {/* table markup */}
    </div>
  )
}

Pagination

import type { PaginationState } from '@tanstack/react-table'

function PaginatedTable({ data }: { data: User[] }) {
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })

  const table = useReactTable({
    data,
    columns,
    state: { pagination },
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    manualPagination: false,  // true for server-side
  })

  return (
    <div>
      {/* table markup */}
      <div className="flex items-center justify-between mt-4">
        <div className="flex gap-2">
          <button onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>«</button>
          <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>‹</button>
          <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>›</button>
          <button onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>»</button>
        </div>
        <span className="text-sm text-gray-500">
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </span>
        <select
          value={table.getState().pagination.pageSize}
          onChange={e => table.setPageSize(Number(e.target.value))}
        >
          {[10, 25, 50, 100].map(size => (
            <option key={size} value={size}>{size} per page</option>
          ))}
        </select>
      </div>
    </div>
  )
}

Row Selection

import type { RowSelectionState } from '@tanstack/react-table'

const selectionColumn = columnHelper.display({
  id: 'select',
  header: ({ table }) => (
    <input
      type="checkbox"
      checked={table.getIsAllPageRowsSelected()}
      indeterminate={table.getIsSomePageRowsSelected()}
      onChange={table.getToggleAllPageRowsSelectedHandler()}
    />
  ),
  cell: ({ row }) => (
    <input
      type="checkbox"
      checked={row.getIsSelected()}
      disabled={!row.getCanSelect()}
      onChange={row.getToggleSelectedHandler()}
    />
  ),
})

function SelectableTable({ data }: { data: User[] }) {
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({})

  const table = useReactTable({
    data,
    columns: [selectionColumn, ...columns],
    state: { rowSelection },
    onRowSelectionChange: setRowSelection,
    getCoreRowModel: getCoreRowModel(),
    enableRowSelection: true,
  })

  const selectedRows = table.getSelectedRowModel().rows.map(r => r.original)

  return (
    <div>
      {selectedRows.length > 0 && (
        <div className="mb-4 p-3 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
          {selectedRows.length} rows selected
          <button onClick={() => bulkDelete(selectedRows)} className="ml-4 text-red-400">
            Delete selected
          </button>
        </div>
      )}
      {/* table markup */}
    </div>
  )
}

Column Visibility

function TableWithVisibility({ data }: { data: User[] }) {
  const [columnVisibility, setColumnVisibility] = useState({})

  const table = useReactTable({
    data,
    columns,
    state: { columnVisibility },
    onColumnVisibilityChange: setColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div>
      {/* Column toggle UI */}
      <div className="flex gap-3 mb-4 flex-wrap">
        {table.getAllLeafColumns().map(column => (
          <label key={column.id} className="flex items-center gap-1 text-sm cursor-pointer">
            <input
              type="checkbox"
              checked={column.getIsVisible()}
              onChange={column.getToggleVisibilityHandler()}
            />
            {column.id}
          </label>
        ))}
      </div>
      {/* table markup */}
    </div>
  )
}

Server-Side Data

function ServerTable() {
  const [sorting, setSorting] = useState<SortingState>([])
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
  const [globalFilter, setGlobalFilter] = useState('')

  // Fetch with server-side params
  const { data, isLoading } = useQuery({
    queryKey: ['users', sorting, pagination, globalFilter],
    queryFn: () => fetchUsers({
      page: pagination.pageIndex + 1,
      pageSize: pagination.pageSize,
      sortBy: sorting[0]?.id,
      sortDir: sorting[0]?.desc ? 'desc' : 'asc',
      search: globalFilter,
    }),
  })

  const table = useReactTable({
    data: data?.rows ?? [],
    columns,
    pageCount: data?.pageCount ?? -1,
    state: { sorting, pagination, globalFilter },
    onSortingChange: setSorting,
    onPaginationChange: setPagination,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,      // Tell TanStack Table not to sort locally
    manualPagination: true,   // Tell TanStack Table not to paginate locally
    manualFiltering: true,    // Tell TanStack Table not to filter locally
  })

  if (isLoading) return <TableSkeleton />
  return <{/* table markup */}>
}