mrkeyoor.com_
Thu 06 Aug 08:50 UTC
npmWeb Frontendupdated 06 Aug 2026

@tanstack/react-table

TanStack Table is a headless datagrid engine. It owns the logic of a table (sorting, filtering, grouping, aggregation, pagination, row selection, column visibility, ordering, pinning, sizing, expansion) and owns none of the rendering. You give it a data array and a list of column definitions; it hands back header groups, rows, and cells along with the handlers and state you need, and you write every table, tr, th, and td yourself with your own CSS. That is the trade: no default look to fight, and no markup you did not write. The React package is a thin adapter over @tanstack/table-core, which also has Vue, Solid, Svelte, Angular, Preact, Lit, Ember, and Alpine adapters, so the same column definitions and mental model carry across frameworks. Version 9, released in August 2026, added a plugin architecture where every feature is opt-in and must be declared up front.

Verdict

The right foundation when the table has to look like your product rather than like a grid widget, and the feature coverage is deeper than anything else headless. Go in knowing v9 is new and the ecosystem's examples have not caught up, so budget time to translate v8 material or stay on v8 until they do.

API stability2/5v9 shipped on 2026-08-04 and changes almost every entry point: features are now mandatory and explicit, row models moved into tableFeatures(), createColumnHelper gained a features generic, and rendering moved to table.FlexRender. The migration shim useLegacyTable is deprecated in the same release that introduced it
Docs4/5tanstack.com/table has per-framework guides, an API reference, and around 60 runnable React examples in the repo that are already updated for v9, plus a features guide that explains the plugin model well. The weakness is a v9 upgrade path: the guides describe the new API rather than mapping v8 concepts onto it
Maintenance5/5Pushed 2026-08-04, 44 open issues (55 counting PRs) against 28.3k stars, a major release just shipped, and there are ten actively maintained framework adapters plus a devtools package in the same repo
Ecosystem5/518M weekly downloads and the layer under Material React Table, Mantine React Table, and the shadcn/ui data table, so most React table code written in the last few years touches it somewhere. The caveat is that the surrounding wrappers are still on v8

Use it if

  • You have a design system or a component library and need the table to render as your components, not as someone else's grid with themes bolted on
  • Your data is server-driven and you want the state machinery (sort direction, page index, filter values, selected row ids) without any client-side processing: keep the feature, drop its row model, set the matching manual option
  • You need the same table behaviour in more than one framework, since table-core is shared and only the adapter changes
  • You care about bundle size and only need part of a datagrid: v9 ships nothing for features you do not register, so a sortable table does not carry grouping and pinning code
Skip it if

Setup reality

npm install @tanstack/react-table is one dependency with no peer deps to chase beyond React 18 or newer, and Node 20 or newer for the build. The work is in the wiring. v9 requires a features option on every table, built with tableFeatures(), and that object should live at module scope because its type flows through your column helper, your column definitions, and the table instance. Data and columns both need stable references: an inline array literal or a columns array rebuilt each render throws away the table's internal structures on every pass, so use useMemo or module-level constants. Rendering headers and cells goes through table.FlexRender rather than the old flexRender call, though flexRender is still exported. useTable takes an optional second argument, a state selector, and omitting it subscribes the component to every registered state slice; narrowing it and using table.Subscribe deeper in the tree is how you stop a whole table re-rendering when one checkbox changes. Devtools live in @tanstack/react-table-devtools and need a key on the table options. If you are coming from v8, useLegacyTable from @tanstack/react-table/legacy gives a v8-shaped API, but it is deprecated on arrival and pulls in every stock feature.

Patterns

Render a table with no optional featuresbasic-table

import { createColumnHelper, tableFeatures, useTable } from '@tanstack/react-table'

type Person = { firstName: string; lastName: string; age: number }

const features = tableFeatures({})
const columnHelper = createColumnHelper<typeof features, Person>()

const columns = columnHelper.columns([
  columnHelper.accessor('firstName', { header: 'First Name', cell: (info) => info.getValue() }),
  columnHelper.accessor((row) => row.lastName, { id: 'lastName', header: () => <span>Last Name</span> }),
  columnHelper.accessor('age', { header: 'Age' }),
])

function PeopleTable({ data }: { data: Array<Person> }) {
  const table = useTable({ features, columns, data })

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((hg) => (
          <tr key={hg.id}>
            {hg.headers.map((header) => (
              <th key={header.id} colSpan={header.colSpan}>
                {header.isPlaceholder ? null : <table.FlexRender header={header} />}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map((row) => (
          <tr key={row.id}>
            {row.getAllCells().map((cell) => (
              <td key={cell.id}><table.FlexRender cell={cell} /></td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

features and columns sit at module scope on purpose. Rebuilding either inside the component discards the table's internal column and row structures on every render, and the features type is what makes columnHelper type-check against your row shape.

Add client-side sortingclient-sorting

import {
  createSortedRowModel,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_text,
  tableFeatures,
} from '@tanstack/react-table'

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})

// in the header cell:
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
  <table.FlexRender header={header} />
  {{ asc: ' \u25B2', desc: ' \u25BC' }[header.column.getIsSorted() as string] ?? null}
</th>

Three separate pieces are needed: the feature for state and APIs, the row model to actually reorder rows, and the named sort functions. Registering only the sortFns you use is what keeps the others out of your bundle; passing a function straight to a column's sortFn option skips registration entirely.

Paginate on the clientclient-pagination

import { createPaginatedRowModel, rowPaginationFeature, tableFeatures } from '@tanstack/react-table'

const features = tableFeatures({
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
})

const table = useTable({
  features,
  columns,
  data,
  initialState: { pagination: { pageIndex: 0, pageSize: 20 } },
})

<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Prev</button>
<span>Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>

Read state from table.state, which is the value your selector produced, rather than reaching into the store. autoResetPageIndex defaults to true, so a filter or sort change bounces you back to page one; set it false if you do not want that.

Filter by column and across the whole tablecolumn-and-global-filters

import {
  columnFilteringFeature,
  createFilteredRowModel,
  filterFn_inNumberRange,
  filterFn_includesString,
  globalFilteringFeature,
  tableFeatures,
} from '@tanstack/react-table'

const features = tableFeatures({
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: { includesString: filterFn_includesString, inNumberRange: filterFn_inNumberRange },
})

// global search box
<input onChange={(e) => table.setGlobalFilter(e.target.value)} />

// per-column input
<input
  value={(column.getFilterValue() ?? '') as string}
  onChange={(e) => column.setFilterValue(e.target.value)}
/>

globalFilteringFeature builds on columnFilteringFeature, so registering it alone is a type error that names the missing prerequisite. Filter inputs fire on every keystroke and re-run the row model over all rows, so debounce the handler once your dataset is more than a few thousand rows.

Checkbox selection with a stable row idrow-selection

import { rowSelectionFeature, tableFeatures } from '@tanstack/react-table'

const features = tableFeatures({ rowSelectionFeature })

const table = useTable({
  features,
  columns,
  data,
  getRowId: (row) => row.id,
  enableRowSelection: true,
})

columnHelper.display({
  id: 'select',
  header: () => (
    <input type="checkbox" checked={table.getIsAllRowsSelected()}
           onChange={table.getToggleAllRowsSelectedHandler()} />
  ),
  cell: ({ row }) => (
    <input type="checkbox" checked={row.getIsSelected()} disabled={!row.getCanSelect()}
           onChange={row.getToggleSelectedHandler()} />
  ),
})

Without getRowId the selection state is keyed by array index, so re-sorting or refetching moves the selection to different records. table.getSelectedRowModel().flatRows gives you the selected rows themselves rather than the id map.

Let users show and hide columnscolumn-visibility

import { columnVisibilityFeature, tableFeatures } from '@tanstack/react-table'

const features = tableFeatures({ columnVisibilityFeature })

const table = useTable({
  features,
  columns,
  data,
  initialState: { columnVisibility: { age: false } },
})

{table.getAllLeafColumns().map((column) => (
  <label key={column.id}>
    <input type="checkbox" checked={column.getIsVisible()}
           onChange={column.getToggleVisibilityHandler()} />
    {column.id}
  </label>
))}

getAllLeafColumns walks past group headers so you get the actual data columns. Set enableHiding: false on a column definition to keep it always visible, and note that hidden columns still hold their filter and sort state.

Keep the state, let the server do the workserver-side-data

import { columnFilteringFeature, rowPaginationFeature, rowSortingFeature, tableFeatures } from '@tanstack/react-table'

// note: no row models registered, so nothing is processed in the browser
const features = tableFeatures({
  rowSortingFeature,
  rowPaginationFeature,
  columnFilteringFeature,
})

const table = useTable({
  features,
  columns,
  data: page.rows,
  manualSorting: true,
  manualPagination: true,
  manualFiltering: true,
  rowCount: page.total,
  state: { sorting, pagination, columnFilters },
  onSortingChange: setSorting,
  onPaginationChange: setPagination,
  onColumnFiltersChange: setColumnFilters,
})

This is the split v9 makes explicit: the feature gives you state and APIs, the row model does client-side work. Omit the row model and the manual flags stop the table reordering or slicing the page you already fetched. Give rowCount (or pageCount) or getPageCount() returns -1.

Stop the whole table re-rendering on every state changenarrow-state-subscription

// only re-render this component when pagination changes
const table = useTable(
  { features, columns, data },
  (state) => ({ pagination: state.pagination }),
)

// subscribe to one slice deeper in the tree instead
<table.Subscribe source={table.atoms.rowSelection} selector={(s) => s?.[row.id]}>
  {(isSelected) => <tr className={isSelected ? 'selected' : ''}>{cells}</tr>}
</table.Subscribe>

Omitting the selector subscribes the component to every registered state slice, which means one checkbox click re-renders the entire table. Selected values are compared shallowly, so return a small object rather than the whole state.

Give cells a typed callback through table metaeditable-cells-with-meta

import { metaHelper, tableFeatures } from '@tanstack/react-table'

type TableMeta = { updateData: (rowIndex: number, columnId: string, value: unknown) => void }

const features = tableFeatures({ tableMeta: metaHelper<TableMeta>() })

columnHelper.accessor('firstName', {
  cell: ({ getValue, row, column, table }) => (
    <input
      defaultValue={getValue() as string}
      onBlur={(e) => table.options.meta?.updateData(row.index, column.id, e.target.value)}
    />
  ),
})

v9 declares meta types in tableFeatures with metaHelper instead of the v8 declaration-merging block, which means two tables in the same app can have different meta shapes. metaHelper is just a typed empty object, so nothing is added at runtime.

Define the feature set once for a whole appshared-table-hook

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

export const { useAppTable, createAppColumnHelper } = createTableHook({
  features: { rowSortingFeature, rowPaginationFeature },
  debugTable: process.env.NODE_ENV === 'development',
})

// in a feature module:
const columnHelper = createAppColumnHelper<Person>()
const columns = columnHelper.columns([columnHelper.accessor('firstName', {})])
const table = useAppTable({ columns, data })

This bakes the features and shared options into a hook so call sites stop repeating typeof features. The trade is one feature set for every table that uses the hook, so a page with one simple table and one full datagrid still ships both feature sets.

Render tens of thousands of rowsvirtualized-rows

import { useVirtualizer } from '@tanstack/react-virtual'

const { rows } = table.getRowModel()
const parentRef = React.useRef<HTMLDivElement>(null)

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  estimateSize: () => 34,
  getScrollElement: () => parentRef.current,
  overscan: 5,
})

<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
  <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
    {rowVirtualizer.getVirtualItems().map((vi) => {
      const row = rows[vi.index]
      return <TableRow key={row.id} row={row} start={vi.start} />
    })}
  </div>
</div>

Virtualization is not part of the table: @tanstack/react-virtual is a separate install. Because you are absolutely positioning rows, semantic table markup stops laying out correctly and you either switch to divs with display: grid or set position and transform on each tr.

Run existing v8 code on v9 while you port itmigrate-from-v8

import {
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  legacyCreateColumnHelper,
  useLegacyTable,
} from '@tanstack/react-table/legacy'
import { flexRender } from '@tanstack/react-table'

const columnHelper = legacyCreateColumnHelper<Person>()

const table = useLegacyTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
})

The legacy entry point keeps the v8 shape: no features option, get*RowModel options, and a subscription to all state. It is deprecated in the release that introduced it and pulls in every stock feature, so treat it as a temporary bridge rather than a destination.

Alternatives

PackageRegistryPick it when
ag-grid-reactnpmYou need a finished enterprise grid with virtualization, editing, clipboard, and Excel export today and can accept the bundle size and the paid tier for advanced features
material-react-tablenpmYou are on Material UI and want a batteries-included table that wraps TanStack Table so you keep the same underlying model
@tanstack/table-corenpmYou are writing your own framework adapter or using a framework with no official package, since the React adapter is a thin layer over this
react-data-gridnpmYou want a rendered, virtualized, editable grid component with a much smaller API surface than AG Grid