Dev.to WebDev 🛠 Dev 👁 1 📖 7 min read

TanStack Table v8: Complete React Data Table Guide (2026)

Building a data table in React that actually works — sorting, filtering, pagination, row selection, and responsive column controls — is surprisingly complex when you do it yourself. TanStack Table (formerly React Table)

Building a data table in React that actually works — sorting, filtering, pagination, row selection, and responsive column controls — is surprisingly complex when you do it yourself. TanStack Table (formerly React Table) is the headless library that handles all the state logic while you own the markup completely. No CSS to fight, no opinionated UI, just the table logic.

This guide covers every feature you'll need for a real admin dashboard or data management interface.

Setup

npm install @tanstack/react-table

TanStack Table is headless — it returns data and handlers, but no HTML. You write the <table> elements yourself, which means it works with any UI library.

Column Definitions

The starting point is defining your columns. Each column knows which field it shows, how to display it, and how to sort/filter:

import { ColumnDef } from '@tanstack/react-table'

type User = {
  id: string
  name: string
  email: string
  role: 'admin' | 'user' | 'viewer'
  status: 'active' | 'inactive'
  createdAt: Date
}

export const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: 'Name',
    cell: ({ row }) => (
      <div className="flex items-center gap-3">
        <Avatar className="h-8 w-8">
          <AvatarFallback>{row.original.name[0]}</AvatarFallback>
        </Avatar>
        <span className="font-medium">{row.getValue('name')}</span>
      </div>
    ),
  },
  {
    accessorKey: 'email',
    header: 'Email',
  },
  {
    accessorKey: 'role',
    header: 'Role',
    cell: ({ row }) => {
      const role = row.getValue<string>('role')
      return (
        <Badge variant={role === 'admin' ? 'default' : 'secondary'}>
          {role}
        </Badge>
      )
    },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => (
      <span className={cn(
        'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
        row.getValue('status') === 'active'
          ? 'bg-green-50 text-green-700'
          : 'bg-gray-100 text-gray-600'
      )}>
        <span className={cn(
          'h-1.5 w-1.5 rounded-full',
          row.getValue('status') === 'active' ? 'bg-green-500' : 'bg-gray-400'
        )} />
        {row.getValue('status')}
      </span>
    ),
  },
  {
    accessorKey: 'createdAt',
    header: 'Created',
    cell: ({ row }) => new Date(row.getValue<Date>('createdAt')).toLocaleDateString(),
  },
]

Basic Table

import {
  useReactTable,
  getCoreRowModel,
  flexRender,
} from '@tanstack/react-table'

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

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(header.column.columnDef.header, header.getContext())}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}

Sorting

import { useState } from 'react'
import { SortingState, getSortedRowModel } from '@tanstack/react-table'
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'

function SortableHeader({ column, children }: {
  column: Column<User, unknown>
  children: React.ReactNode
}) {
  return (
    <Button
      variant="ghost"
      onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
      className="-ml-3 h-8"
    >
      {children}
      {column.getIsSorted() === 'asc' ? (
        <ArrowUp className="ml-2 h-4 w-4" />
      ) : column.getIsSorted() === 'desc' ? (
        <ArrowDown className="ml-2 h-4 w-4" />
      ) : (
        <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
      )}
    </Button>
  )
}

// In column definition:
{
  accessorKey: 'name',
  header: ({ column }) => <SortableHeader column={column}>Name</SortableHeader>,
}

// In the table:
const [sorting, setSorting] = useState<SortingState>([])

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

Filtering

Global filter (search all columns)

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

const [globalFilter, setGlobalFilter] = useState('')

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

// In your JSX:
<Input
  placeholder="Search all columns..."
  value={globalFilter}
  onChange={(e) => setGlobalFilter(e.target.value)}
  className="max-w-sm"
/>

Column-specific filter

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

const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

const table = useReactTable({
  state: { columnFilters },
  onColumnFiltersChange: setColumnFilters,
  getFilteredRowModel: getFilteredRowModel(),
})

// Filter input for a specific column:
<Input
  placeholder="Filter by email..."
  value={(table.getColumn('email')?.getFilterValue() as string) ?? ''}
  onChange={(e) => table.getColumn('email')?.setFilterValue(e.target.value)}
/>

// Filter by select (e.g. role):
<Select
  value={(table.getColumn('role')?.getFilterValue() as string) ?? 'all'}
  onValueChange={(value) =>
    table.getColumn('role')?.setFilterValue(value === 'all' ? '' : value)
  }
>
  <SelectTrigger><SelectValue placeholder="All roles" /></SelectTrigger>
  <SelectContent>
    <SelectItem value="all">All roles</SelectItem>
    <SelectItem value="admin">Admin</SelectItem>
    <SelectItem value="user">User</SelectItem>
  </SelectContent>
</Select>

Pagination

import { getPaginationRowModel } from '@tanstack/react-table'

const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })

const table = useReactTable({
  state: { pagination },
  onPaginationChange: setPagination,
  getCoreRowModel: getCoreRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
})

function TablePagination({ table }: { table: ReactTable<User> }) {
  return (
    <div className="flex items-center justify-between px-2 py-4">
      <p className="text-sm text-muted-foreground">
        {table.getFilteredSelectedRowModel().rows.length > 0 && (
          <>{table.getFilteredSelectedRowModel().rows.length} of{' '}</>
        )}
        {table.getFilteredRowModel().rows.length} row(s)
      </p>

      <div className="flex items-center gap-6">
        <div className="flex items-center gap-2">
          <span className="text-sm text-muted-foreground">Rows per page</span>
          <Select
            value={String(table.getState().pagination.pageSize)}
            onValueChange={(v) => table.setPageSize(Number(v))}
          >
            <SelectTrigger className="h-8 w-16">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {[10, 20, 50, 100].map((size) => (
                <SelectItem key={size} value={String(size)}>{size}</SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>

        <span className="text-sm text-muted-foreground">
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </span>

        <div className="flex gap-1">
          <Button variant="outline" size="icon" className="h-8 w-8"
            onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>
            <ChevronsLeft className="h-4 w-4" />
          </Button>
          <Button variant="outline" size="icon" className="h-8 w-8"
            onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button variant="outline" size="icon" className="h-8 w-8"
            onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
            <ChevronRight className="h-4 w-4" />
          </Button>
          <Button variant="outline" size="icon" className="h-8 w-8"
            onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>
            <ChevronsRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  )
}

Row Selection

import { RowSelectionState } from '@tanstack/react-table'
import { Checkbox } from '@/components/ui/checkbox'

const [rowSelection, setRowSelection] = useState<RowSelectionState>({})

const selectionColumn: ColumnDef<User> = {
  id: 'select',
  header: ({ table }) => (
    <Checkbox
      checked={
        table.getIsAllPageRowsSelected() ||
        (table.getIsSomePageRowsSelected() && 'indeterminate')
      }
      onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
      aria-label="Select all"
    />
  ),
  cell: ({ row }) => (
    <Checkbox
      checked={row.getIsSelected()}
      onCheckedChange={(value) => row.toggleSelected(!!value)}
      aria-label="Select row"
    />
  ),
  enableSorting: false,
  enableHiding: false,
  size: 40,
}

// Use selected rows:
const selectedUsers = table.getFilteredSelectedRowModel().rows.map(r => r.original)

Column Visibility

import { VisibilityState } from '@tanstack/react-table'

const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline" size="sm">
      <SlidersHorizontal className="mr-2 h-4 w-4" />
      Columns
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    {table.getAllColumns()
      .filter((col) => col.getCanHide())
      .map((col) => (
        <DropdownMenuCheckboxItem
          key={col.id}
          checked={col.getIsVisible()}
          onCheckedChange={(value) => col.toggleVisibility(!!value)}
        >
          {col.id}
        </DropdownMenuCheckboxItem>
      ))}
  </DropdownMenuContent>
</DropdownMenu>

Server-Side Table

For large datasets, fetch only the current page and let the server handle sorting and filtering:

const table = useReactTable({
  data,
  columns,
  pageCount: totalPageCount,
  state: { pagination, sorting, globalFilter },
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
  onGlobalFilterChange: setGlobalFilter,
  getCoreRowModel: getCoreRowModel(),
  manualPagination: true,   // tells TanStack not to paginate client-side
  manualSorting: true,      // tells TanStack not to sort client-side
  manualFiltering: true,    // tells TanStack not to filter client-side
})

The three manual* flags are the key difference. When set, TanStack Table treats its state changes as signals to your useEffect / data fetching layer — it doesn't process the data itself.

Export to CSV

function exportToCSV(table: ReactTable<User>) {
  const headers = table.getAllColumns()
    .filter(col => col.getIsVisible() && col.id !== 'select' && col.id !== 'actions')
    .map(col => col.id)

  const rows = table.getFilteredRowModel().rows.map(row =>
    headers.map(header => {
      const value = row.getValue(header)
      const str = String(value ?? '')
      return str.includes(',') || str.includes('"')
        ? `"${str.replace(/"/g, '""')}"`
        : str
    }).join(',')
  )

  const csv = [headers.join(','), ...rows].join('\n')
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = `export-${new Date().toISOString().split('T')[0]}.csv`
  link.click()
  URL.revokeObjectURL(url)
}

Quick Reference

// Core setup
const table = useReactTable({
  data,
  columns,
  state: { sorting, columnFilters, columnVisibility, rowSelection, globalFilter, pagination },
  onSortingChange: setSorting,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
})

// Server-side: use manualPagination, manualSorting, manualFiltering: true + pageCount from server

// Get selected rows
table.getFilteredSelectedRowModel().rows.map(r => r.original)

// Column filter
table.getColumn('email')?.setFilterValue(value)

// Toggle column visibility
column.toggleVisibility(true/false)

Full article at stacknotice.com/blog/tanstack-table-react-guide-2026

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.