# Data Tables with TanStack Table

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

**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.

**Tip:** 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.

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

**Pattern** — `features/examples/components/UserTable.tsx`

```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/common';
import { useUsers } from '@/features/examples/hooks/useUsers';

const columns: ColumnDef<User>[] = [
  { accessorKey: 'name',   header: 'Name' },
  { accessorKey: 'email',  header: 'Email' },
  { accessorKey: 'role',   header: 'Role' },
  { accessorKey: 'status', header: 'Status' },
];

// The table owns its data: it calls the query hook internally, so pages render
// <UserTable /> with no props. Server state stays in React Query's cache.
export function UserTable() {
  const { data } = useUsers({ limit: 100 });
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState('');
  const cols = useMemo(() => columns, []);

  const table = useReactTable({
    data: data?.users ?? [], 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>
  );
}
```

**Implementation — Next.js**

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.

File: `app/users/page.tsx`

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

export default async function UsersPage() {
  const queryClient = getQueryClient();
  // Match the key UserTable's internal useUsers({ limit: 100 }) reads from,
  // so the hydrated cache resolves on first render — no loading flash.
  await queryClient.prefetchQuery({
    queryKey: queryKeys.users.list({ limit: 100 }),
    queryFn: () => usersService.getAll({ limit: 100 }),
  });

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

**Implementation — Vite**

In Vite, the page stays declarative — UserTable calls the useUsers hook internally, so it owns its own data. For datasets under 1,000 rows, all filtering, sorting, and pagination can stay client-side.

File: `pages/UsersPage.tsx`

```tsx
import { UserTable } from '@/features/examples';

export function UsersPage() {
  return <UserTable />;
}
```

Read more: https://reactprinciples.dev/cookbook/data-tables

---

Source: https://reactprinciples.dev/nextjs/cookbook/data-tables
All recipes (compact): https://reactprinciples.dev/llms.txt
All recipes (full): https://reactprinciples.dev/llms-full.txt
