Skip to content

Fix: TanStack Table Not Working — Sorting Not Triggering, Filters Ignored, or Pagination Showing Wrong Data

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix TanStack Table (React Table v8) issues — column definitions, server-side sorting and filtering, row selection, virtual rows with TanStack Virtual, and v7 to v8 migration errors.

The Problem

Clicking a column header to sort does nothing:

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
});
// Clicking headers — no sort, no re-render

Or the filter input updates but the table rows don’t change:

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

const table = useReactTable({
  data,
  columns,
  state: { globalFilter },
  onGlobalFilterChange: setGlobalFilter,
  getCoreRowModel: getCoreRowModel(),
});
// Filter state updates but rows are unchanged

Or pagination shows the same rows on every page:

const table = useReactTable({
  data: allRows,  // 1000 rows
  columns,
  getPaginationRowModel: getPaginationRowModel(),
  getCoreRowModel: getCoreRowModel(),
});
// Page 2 shows identical rows to page 1

Or after upgrading from v7 to v8, types break everywhere:

// v7
const { rows } = useTable({ columns, data });
// Error: Property 'useTable' does not exist

Why This Happens

TanStack Table v8 requires explicit opt-in for every feature:

  • Row models must be explicitly registeredgetCoreRowModel() is the only required model. Sorting, filtering, and pagination each need their own model (getSortedRowModel, getFilteredRowModel, getPaginationRowModel) plus the corresponding state and event handler.
  • state must be controlled — features like sorting require you to declare both the state object and the on*Change handler. Declaring one without the other leaves the feature in a broken half-controlled state.
  • v8 is a complete rewriteuseTable, useSortBy, useFilters, etc. are gone. Everything is now in a single useReactTable hook with a composable API. Column definitions use columnHelper and the type system is entirely different.
  • Client-side vs server-side modes — by default, TanStack Table processes all data client-side. For server-side sorting/filtering, you must pass manualSorting: true, manualFiltering: true, and handle the logic yourself.

The reason TanStack Table feels harder than other grids is that it is intentionally headless. There are no DOM elements, no CSS, no built-in scroll containers — only state, models, and helpers. That gives you total visual control, but it also means every feature is opt-in plumbing. Sorting that “just worked” in a Material UI DataGrid requires four things in TanStack: the row model, the state slice, the change handler, and a click target on the header. Miss any one and the feature appears broken even though no error is thrown.

A second confusion source is the v7 to v8 migration. v7 used a plugin hook system (useSortBy, useFilters, useGroupBy) layered onto a base useTable. v8 collapsed everything into one useReactTable with row models passed as functions. Stack Overflow answers older than mid-2023 mostly describe v7 and won’t compile against v8 types. The official codemod handles surface-level renames but doesn’t translate ref-based prop spreads (getRowProps()) into v8’s explicit handlers, which is where most porting hours go.

How Other Tools Handle This

Picking a React table library is mostly a choice about how much UI you want shipped for you.

TanStack Table vs AG Grid. AG Grid ships a full enterprise grid: pinned columns, range selection, master-detail rows, Excel export, virtualization, and a CSS theme system are built in. You configure column definitions and AG Grid renders the whole table. It costs you bundle size (~400 KB minified) and a paid license for enterprise features. TanStack Table is roughly 14 KB and ships zero UI, so a comparable AG Grid screen is one config object versus several hundred lines of JSX, but the JSX is yours to style.

TanStack Table vs MUI DataGrid. MUI DataGrid is the React grid most teams reach for if they already use MUI. Sorting, filtering, and pagination are on by default; you flip props rather than wiring row models. The free version caps multi-column sorting and column pinning behind the paid Pro/Premium tiers. TanStack gives you those features for free, but you write the chrome.

TanStack Table vs ReactDataGrid (Inovua). ReactDataGrid focuses on virtualization performance for very large datasets — millions of rows scroll smoothly because it virtualizes both rows and columns. TanStack relies on @tanstack/react-virtual for the same effect, but you wire the virtualizer to the row model yourself.

TanStack Table vs Handsontable. Handsontable is spreadsheet-shaped: editable cells, formulas, copy-paste from Excel, validation. If your users expect Excel behavior, Handsontable is closer to the metal. TanStack is a data-display grid and treats cell editing as a custom render.

Rule of thumb. Pick AG Grid or MUI DataGrid when you want batteries included and don’t mind their visual opinion. Pick TanStack when your design system requires custom chrome and you accept the extra plumbing. Pick Handsontable when “spreadsheet” is the actual requirement.

Fix 1: Set Up useReactTable Correctly

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

interface User {
  id: number;
  name: string;
  email: string;
  role: 'user' | 'admin';
  createdAt: string;
}

const columnHelper = createColumnHelper<User>();

const columns = [
  columnHelper.accessor('id', {
    header: 'ID',
    cell: info => info.getValue(),
  }),
  columnHelper.accessor('name', {
    header: 'Name',
    cell: info => info.getValue(),
    enableSorting: true,
    enableColumnFilter: true,
  }),
  columnHelper.accessor('email', {
    header: 'Email',
  }),
  columnHelper.accessor('role', {
    header: 'Role',
    filterFn: 'equals',  // Exact match for enum columns
  }),
  columnHelper.display({
    id: 'actions',
    header: 'Actions',
    cell: ({ row }) => (
      <button onClick={() => handleEdit(row.original)}>Edit</button>
    ),
  }),
];

function UserTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState([]);
  const [columnFilters, setColumnFilters] = useState([]);
  const [globalFilter, setGlobalFilter] = useState('');
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
  const [rowSelection, setRowSelection] = useState({});

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      columnFilters,
      globalFilter,
      pagination,
      rowSelection,
    },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setGlobalFilter,
    onPaginationChange: setPagination,
    onRowSelectionChange: setRowSelection,
    // Row models — register only what you use
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    // Row count for pagination (required for server-side)
    // rowCount: serverTotalRows,
  });

  return (
    <div>
      <input
        value={globalFilter}
        onChange={e => setGlobalFilter(e.target.value)}
        placeholder="Search all columns..."
      />

      <table>
        <thead>
          {table.getHeaderGroups().map(headerGroup => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map(header => (
                <th key={header.id}>
                  {header.isPlaceholder ? null : (
                    <div
                      style={{ cursor: header.column.getCanSort() ? 'pointer' : 'default' }}
                      onClick={header.column.getToggleSortingHandler()}
                    >
                      {flexRender(header.column.columnDef.header, header.getContext())}
                      {header.column.getIsSorted() === 'asc' ? ' ↑'
                        : header.column.getIsSorted() === 'desc' ? ' ↓'
                        : null}
                    </div>
                  )}
                </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>

      <div>
        <button onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>«</button>
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}></button>
        <span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}></button>
        <button onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>»</button>
        <select
          value={table.getState().pagination.pageSize}
          onChange={e => table.setPageSize(Number(e.target.value))}
        >
          {[10, 20, 50, 100].map(size => (
            <option key={size} value={size}>Show {size}</option>
          ))}
        </select>
      </div>
    </div>
  );
}

Fix 2: Add Row Selection

import { createColumnHelper } from '@tanstack/react-table';

// Add a checkbox column
const columns = [
  {
    id: 'select',
    header: ({ table }) => (
      <input
        type="checkbox"
        checked={table.getIsAllPageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
        ref={el => {
          if (el) el.indeterminate = table.getIsSomePageRowsSelected();
        }}
      />
    ),
    cell: ({ row }) => (
      <input
        type="checkbox"
        checked={row.getIsSelected()}
        disabled={!row.getCanSelect()}
        onChange={row.getToggleSelectedHandler()}
      />
    ),
  },
  // ...other columns
];

// Table config
const table = useReactTable({
  data,
  columns,
  state: { rowSelection },
  onRowSelectionChange: setRowSelection,
  enableRowSelection: true,             // Enable for all rows
  // enableRowSelection: row => row.original.role !== 'admin',  // Conditional
  getCoreRowModel: getCoreRowModel(),
});

// Get selected rows
const selectedRows = table.getSelectedRowModel().rows;
const selectedData = selectedRows.map(row => row.original);

// Bulk action
<button
  disabled={selectedRows.length === 0}
  onClick={() => handleBulkDelete(selectedData)}
>
  Delete {selectedRows.length} selected
</button>

Fix 3: Server-Side Data

For large datasets, handle sorting/filtering/pagination on the server:

function ServerSideTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });

  // Fetch from server whenever state changes
  const { data, isLoading } = useQuery({
    queryKey: ['users', { sorting, columnFilters, pagination }],
    queryFn: () => fetchUsers({
      page: pagination.pageIndex,
      pageSize: pagination.pageSize,
      sortBy: sorting[0]?.id,
      sortDir: sorting[0]?.desc ? 'desc' : 'asc',
      filters: columnFilters,
    }),
  });

  const table = useReactTable({
    data: data?.rows ?? [],
    columns,
    state: { sorting, columnFilters, pagination },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onPaginationChange: setPagination,
    // Tell TanStack Table that the server handles these
    manualSorting: true,
    manualFiltering: true,
    manualPagination: true,
    rowCount: data?.totalCount,   // Required for page count calculation
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div>
      {isLoading && <span>Loading...</span>}
      {/* Table rendering */}
    </div>
  );
}

Fix 4: Custom Filter Functions

import { FilterFn, filterFns } from '@tanstack/react-table';

// Custom date range filter
const dateRangeFilter: FilterFn<any> = (row, columnId, filterValue) => {
  const [start, end] = filterValue as [Date, Date];
  const date = new Date(row.getValue(columnId));
  if (start && date < start) return false;
  if (end && date > end) return false;
  return true;
};
dateRangeFilter.autoRemove = (val) => !val || (!val[0] && !val[1]);

// Multi-value filter (checkbox group)
const multiValueFilter: FilterFn<any> = (row, columnId, filterValue) => {
  const values = filterValue as string[];
  if (!values.length) return true;
  return values.includes(row.getValue(columnId));
};
multiValueFilter.autoRemove = (val) => !val || val.length === 0;

// Register and use
const columns = [
  columnHelper.accessor('createdAt', {
    filterFn: dateRangeFilter,
  }),
  columnHelper.accessor('role', {
    filterFn: multiValueFilter,
  }),
];

Fix 5: Virtualized Rows for Large Datasets

For tables with thousands of rows, use @tanstack/react-virtual:

npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

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

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

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,  // Row height in px
    overscan: 10,            // Render extra rows above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <table style={{ width: '100%' }}>
        <thead>
          {table.getHeaderGroups().map(hg => (
            <tr key={hg.id}>
              {hg.headers.map(h => (
                <th key={h.id} onClick={h.column.getToggleSortingHandler()}>
                  {flexRender(h.column.columnDef.header, h.getContext())}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody
          style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
        >
          {virtualizer.getVirtualItems().map(virtualRow => {
            const row = rows[virtualRow.index];
            return (
              <tr
                key={row.id}
                style={{
                  position: 'absolute',
                  top: 0,
                  transform: `translateY(${virtualRow.start}px)`,
                  height: `${virtualRow.size}px`,
                }}
              >
                {row.getVisibleCells().map(cell => (
                  <td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

Fix 6: Migrate from v7 to v8

// v7 → v8 key changes

// 1. useTable() → useReactTable()
// v7:
import { useTable, useSortBy, useFilters } from 'react-table';
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
  useTable({ columns, data }, useFilters, useSortBy);

// v8:
import { useReactTable, getCoreRowModel, getSortedRowModel } from '@tanstack/react-table';
const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  state: { sorting },
  onSortingChange: setSorting,
});

// 2. Column definitions changed
// v7:
const columns = [{ accessor: 'name', Header: 'Name' }];
// v8:
const columnHelper = createColumnHelper<User>();
const columns = [columnHelper.accessor('name', { header: 'Name' })];

// 3. Rendering changed
// v7:
rows.map(row => {
  prepareRow(row);
  return <tr {...row.getRowProps()}>...</tr>;
});
// v8:
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>
));

// 4. Props spread → explicit props
// v7: {...column.getHeaderProps(column.getSortByToggleProps())}
// v8: onClick={header.column.getToggleSortingHandler()}

Still Not Working?

Sorting arrow shows but data order doesn’t change — you likely registered getSortedRowModel() but forgot to add sorting to state and onSortingChange to the table config. Without both, the sort state is internal but doesn’t drive rendering. All three pieces are required: the row model, the state slice, and the change handler.

Global filter doesn’t filter on all columnsgetFilteredRowModel with globalFilter only searches columns where enableGlobalFilter is not false. By default it’s enabled. If filtering still doesn’t work, check that getGlobalAutoFilterFn is appropriate for your data types. For custom filter logic, provide globalFilterFn to the table config.

flexRender returns undefined or crashes — this happens when the cell definition is undefined in a column. Every column must have a cell definition (or use the default accessor which renders the value as a string). For display columns (columnHelper.display), always provide a cell function explicitly.

data reference changes on every render causing reset state — TanStack Table watches the data prop by reference. If you build the array inside the parent’s render (data={users.filter(...)}), every render is a new array and internal state may reset. Memoize with useMemo so the reference is stable until the underlying data actually changes.

Column resizing snaps back to default widthenableColumnResizing: true alone isn’t enough. You also need columnResizeMode: 'onChange' (or 'onEnd') and you must read header.getSize() plus apply it as inline style width on the <th> and <td>. Without the style, the resize state changes but the DOM doesn’t reflect it.

Row selection state desyncs after data refetch — selection is keyed by row.id, which defaults to the array index. If your data refetches and rows shift position, the wrong rows appear selected. Provide getRowId: row => row.id to key selection on a stable identifier.

For related data display issues, see Fix: React useState Not Updating and Fix: TanStack Query Not Working. For other React data-flow gotchas, see Fix: React Too Many Re-Renders and Fix: TanStack Form Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles