Data Tables with TanStack Table

Headless, sortable, filterable, and paginated tables using TanStack Table v8. Full styling control with no component library lock-in.

01

Principle

TanStack Table is a headless engine — it computes row models, manages sorting, filtering, and pagination state, but renders nothing. You own the markup. This separation means complete styling control without fighting a component library.

lightbulb

Wrap column definitions in useMemo with an empty dependency array. Column definitions are stable references — recreating them on every render causes unnecessary row model recalculations.

02

Rules

  • check_circle
    Columns are stableWrap column definitions in useMemo(() => [...], []). Redefining them each render triggers unnecessary re-sorts and re-filters.
  • check_circle
    Own the render loopUse flexRender() for both headers and cells. Never manually extract cell values — let the column definition handle rendering.
  • check_circle
    Server-side for large dataClient-side filtering and sorting works up to ~1,000 rows. Beyond that, move pagination and filtering to the server.
  • check_circle
    Global vs column filtersUse globalFilter for quick full-text search. Use column-level filters for advanced filtering UI with per-field controls.
03

Pattern

components/UserTable.tsx
import { useMemo, useState } from 'react';
import {
  useReactTable, getCoreRowModel, getSortedRowModel,
  getFilteredRowModel, getPaginationRowModel,
  flexRender, type ColumnDef, type SortingState,
} from '@tanstack/react-table';
import type { User } from '@/shared/types/user';

const columns: ColumnDef<User>[] = [
  {
    id: 'name',
    header: 'Name',
    // accessorFn combines two fields into one sortable, filterable column
    accessorFn: (row) => `${row.firstName} ${row.lastName}`,
  },
  { accessorKey: 'email',  header: 'Email' },
  { accessorKey: 'age',    header: 'Age' },
  { accessorKey: 'gender', header: 'Gender' },
];

export function UserTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState('');
  const cols = useMemo(() => columns, []);

  const table = useReactTable({
    data, columns: cols,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <div>
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Filter all columns..."
      />
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                >
                  {flexRender(header.column.columnDef.header, header.getContext())}
                  {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null}
                </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.previousPage()} disabled={!table.getCanPreviousPage()}>
          Previous
        </button>
        <span>
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
          Next
        </button>
      </div>
    </div>
  );
}
04

Implementation

info

Version Compatibility

Requires React 19+ and the latest stable versions of all dependencies shown.

In Next.js, prefetch user data in a Server Component and hydrate it via HydrationBoundary. The table renders immediately with cached data while staying reactive to updates.

app/users/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';
import { UserTable } from '@/features/users';

export default async function UsersPage() {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery({
    queryKey: queryKeys.users.all,
    queryFn: () => usersService.getAll({ limit: 100 }),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserTable />
    </HydrationBoundary>
  );
}
open_in_new

View UserTable in starter

View the real implementation in react-principles-nextjs

arrow_forward
05

Live Demo

Name
Email
Role
Status
Created
Alice Johnsonalice@example.comadminactiveJan 15, 2024
Bob Smithbob@example.comeditoractiveFeb 10, 2024
Carol Williamscarol@example.comvieweractiveFeb 20, 2024
David Browndavid@example.comeditorinactiveMar 5, 2024
Eva Martinezeva@example.comadminactiveMar 12, 2024
Frank Garciafrank@example.comvieweractiveMar 18, 2024
Grace Leegrace@example.comeditoractiveApr 1, 2024
Henry Wilsonhenry@example.comviewerinactiveApr 10, 2024

Page 1 of 3 (20 rows)

menu_book
React Patterns

Helping developers build robust React applications since 2026.

© 2026 React Patterns Cookbook. Built with ❤️ for the community.
React Principles