Next.js course Β· Module 9: Integrations and Advanced Features

Next.js with TanStack Table - Advanced data tables

22 min read
In this lesson11

The guardians' panel of Metropolis Quantum shows thousands of records: users, roles, statuses. A plain table element with a map loop is enough for a week, and then someone asks for sorting, a search box, pagination and selecting many rows at once. Writing all of that by hand means hundreds of lines of fragile logic. Matrix, the data architect of the Metropolis, solves it with TanStack Table - a library that computes the table logic and leaves the look to you.

What is TanStack Table?

TanStack Table (formerly React Table) is a headless library for building tables that offers:

  • Headless UI - full control over the look
  • TypeScript-first - full typing support
  • Framework agnostic - works with React, Vue, Svelte
  • Feature-rich - sorting, filtering, pagination, grouping
  • Performance - optimizations for large datasets
  • Extensible - a rich plugin system

"Headless" means the library renders no HTML at all. You get an API with state and methods, and you write the markup and styles yourself.

Advantages of TanStack Table

  • No UI dependencies - no ready-made components or styles
  • Tree-shakable - only the features you need in the bundle
  • Server-side support - ideal for SSR/SSG
  • Accessibility - accessibility depends on your HTML, because you write it
  • Mobile-friendly - responsive tables

What TanStack Table does NOT do: it does not fetch data and has no built-in virtualization. You supply the data yourself (for example with TanStack Query), and virtualization of long lists comes from the separate TanStack Virtual library.

Installation and basic setup

Installation

In a React project you install the @tanstack/react-table adapter, which already contains the library core:

1# Install TanStack Table
2npm install @tanstack/react-table
3
4# Framework-agnostic core (react-table installs it automatically)
5npm install @tanstack/table-core

The @tanstack/table-core package is the framework-agnostic core. The React adapter installs it automatically, so a separate installation is optional.

Basic table

Every table starts with a data type. The User interface describes one row, and literal unions, such as 'admin' | 'user' | 'moderator', limit the allowed values:

1// types/User.ts
2export interface User {
3  id: string;
4  name: string;
5  email: string;
6  role: 'admin' | 'user' | 'moderator';
7  status: 'active' | 'inactive' | 'pending';
8  createdAt: string;
9  lastLogin?: string;
10}

Based on this, TypeScript will suggest column names and catch typos in keys.

Now we define the columns. The createColumnHelper<User>() function creates a helper whose accessor method points to an object field, while cell decides how to render the value:

1// components/UsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  useReactTable,
9} from '@tanstack/react-table';
10import { User } from '@/types/User';
11
12const columnHelper = createColumnHelper<User>();
13
14const columns = [
15  columnHelper.accessor('name', {
16    header: 'Username',
17    cell: (info) => info.getValue(),
18  }),
19  columnHelper.accessor('email', {
20    header: 'Email',
21    cell: (info) => (
22      <a href={`mailto:${info.getValue()}`} className="text-blue-600 hover:underline">
23        {info.getValue()}
24      </a>
25    ),
26  }),
27  columnHelper.accessor('role', {
28    header: 'Role',
29    cell: (info) => {
30      const role = info.getValue();
31      const roleColors = {
32        admin: 'bg-red-100 text-red-800',
33        moderator: 'bg-yellow-100 text-yellow-800',
34        user: 'bg-green-100 text-green-800',
35      };
36
37      return (
38        <span className={`px-2 py-1 rounded-full text-xs font-medium ${roleColors[role]}`}>
39          {role}
40        </span>
41      );
42    },
43  }),
44  columnHelper.accessor('status', {
45    header: 'Status',
46    cell: (info) => {
47      const status = info.getValue();
48      const statusColors = {
49        active: 'bg-green-100 text-green-800',
50        inactive: 'bg-gray-100 text-gray-800',
51        pending: 'bg-yellow-100 text-yellow-800',
52      };
53
54      return (
55        <span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[status]}`}>
56          {status}
57        </span>
58      );
59    },
60  }),
61  columnHelper.accessor('createdAt', {
62    header: 'Created at',
63    cell: (info) => new Date(info.getValue()).toLocaleDateString('en-US'),
64  }),
65];
66

The columns are just an array of configuration, with no table yet. Role and status get colored badges, and the date is formatted for the US English locale.

The component connects data and columns with the useReactTable hook. The minimal configuration is data, columns and getCoreRowModel, the basic row model:

1interface UsersTableProps {
2  data: User[];
3}
4
5export default function UsersTable({ data }: UsersTableProps) {
6  const table = useReactTable({
7    data,
8    columns,
9    getCoreRowModel: getCoreRowModel(),
10  });
11
12  return (
13    <div className="overflow-x-auto">
14      <table className="min-w-full bg-white border border-gray-200">
15        <thead className="bg-gray-50">
16          {table.getHeaderGroups().map((headerGroup) => (
17            <tr key={headerGroup.id}>
18              {headerGroup.headers.map((header) => (
19                <th
20                  key={header.id}
21                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
22                >
23                  {header.isPlaceholder
24                    ? null
25                    : flexRender(header.column.columnDef.header, header.getContext())}
26                </th>
27              ))}
28            </tr>
29          ))}
30        </thead>
31        <tbody className="bg-white divide-y divide-gray-200">
32          {table.getRowModel().rows.map((row) => (
33            <tr key={row.id} className="hover:bg-gray-50">
34              {row.getVisibleCells().map((cell) => (
35                <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
36                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
37                </td>
38              ))}
39            </tr>
40          ))}
41        </tbody>
42      </table>
43    </div>
44  );
45}

flexRender renders a header or a cell regardless of whether the definition holds text or a function. All the HTML and Tailwind classes are ours: the library only provided header groups and rows.

Sorting and filtering

Sorting

Every table feature follows the same scheme: state in useState, wiring it through state and on...Change, and adding the matching row model. For sorting that means SortingState and getSortedRowModel:

1// components/SortableUsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  getSortedRowModel,
9  SortingState,
10  useReactTable,
11} from '@tanstack/react-table';
12import { useState } from 'react';
13import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
14import { User } from '@/types/User';
15
16const columnHelper = createColumnHelper<User>();
17
18const columns = [
19  columnHelper.accessor('name', {
20    header: 'Username',
21    enableSorting: true,
22  }),
23  columnHelper.accessor('email', {
24    header: 'Email',
25    enableSorting: true,
26  }),
27  columnHelper.accessor('role', {
28    header: 'Role',
29    enableSorting: true,
30  }),
31  columnHelper.accessor('status', {
32    header: 'Status',
33    enableSorting: true,
34  }),
35  columnHelper.accessor('createdAt', {
36    header: 'Created at',
37    enableSorting: true,
38    sortingFn: 'datetime',
39    cell: (info) => new Date(info.getValue()).toLocaleDateString('en-US'),
40  }),
41];
42
43interface SortableUsersTableProps {
44  data: User[];
45}
46
47export default function SortableUsersTable({ data }: SortableUsersTableProps) {
48  const [sorting, setSorting] = useState<SortingState>([]);
49
50  const table = useReactTable({
51    data,
52    columns,
53    state: {
54      sorting,
55    },
56    onSortingChange: setSorting,
57    getCoreRowModel: getCoreRowModel(),
58    getSortedRowModel: getSortedRowModel(),
59  });
60
61  return (
62    <div className="overflow-x-auto">
63      <table className="min-w-full bg-white border border-gray-200">
64        <thead className="bg-gray-50">
65          {table.getHeaderGroups().map((headerGroup) => (
66            <tr key={headerGroup.id}>
67              {headerGroup.headers.map((header) => (
68                <th
69                  key={header.id}
70                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
71                >
72                  <div
73                    className={
74                      header.column.getCanSort()
75                        ? 'flex items-center cursor-pointer select-none hover:text-gray-700'
76                        : ''
77                    }
78                    onClick={header.column.getToggleSortingHandler()}
79                  >
80                    {flexRender(header.column.columnDef.header, header.getContext())}
81                    {header.column.getCanSort() && (
82                      <span className="ml-1">
83                        {header.column.getIsSorted() === 'asc' ? (
84                          <ChevronUpIcon className="w-4 h-4" />
85                        ) : header.column.getIsSorted() === 'desc' ? (
86                          <ChevronDownIcon className="w-4 h-4" />
87                        ) : (
88                          <div className="w-4 h-4" />
89                        )}
90                      </span>
91                    )}
92                  </div>
93                </th>
94              ))}
95            </tr>
96          ))}
97        </thead>
98        <tbody className="bg-white divide-y divide-gray-200">
99          {table.getRowModel().rows.map((row) => (
100            <tr key={row.id} className="hover:bg-gray-50">
101              {row.getVisibleCells().map((cell) => (
102                <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
103                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
104                </td>
105              ))}
106            </tr>
107          ))}
108        </tbody>
109      </table>
110    </div>
111  );
112}

Clicking a header calls getToggleSortingHandler(), which cycles the order: ascending, descending, unsorted. sortingFn: 'datetime' sorts dates chronologically rather than alphabetically.

Filtering

Filtering adds two kinds of state: column filters (ColumnFiltersState) and one global filter for the search box. First the configuration with a custom globalFilterFn function:

1// components/FilterableUsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  getFilteredRowModel,
9  getSortedRowModel,
10  SortingState,
11  ColumnFiltersState,
12  useReactTable,
13  GlobalFilterTableState,
14} from '@tanstack/react-table';
15import { useState } from 'react';
16import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
17import { User } from '@/types/User';
18
19// Global filter function
20function globalFilterFn(row: any, columnId: string, value: string) {
21  const search = value.toLowerCase();
22  return (
23    row.getValue('name')?.toLowerCase().includes(search) ||
24    row.getValue('email')?.toLowerCase().includes(search) ||
25    row.getValue('role')?.toLowerCase().includes(search)
26  );
27}
28
29const columnHelper = createColumnHelper<User>();
30
31const columns = [
32  columnHelper.accessor('name', {
33    header: 'Username',
34    enableColumnFilter: true,
35    filterFn: 'includesString',
36  }),
37  columnHelper.accessor('email', {
38    header: 'Email',
39    enableColumnFilter: true,
40  }),
41  columnHelper.accessor('role', {
42    header: 'Role',
43    enableColumnFilter: true,
44    filterFn: 'equals',
45  }),
46  columnHelper.accessor('status', {
47    header: 'Status',
48    enableColumnFilter: true,
49    filterFn: 'equals',
50  }),
51  columnHelper.accessor('createdAt', {
52    header: 'Created at',
53    enableColumnFilter: false,
54    cell: (info) => new Date(info.getValue()).toLocaleDateString('en-US'),
55  }),
56];
57
58interface FilterableUsersTableProps {
59  data: User[];
60}
61
62export default function FilterableUsersTable({ data }: FilterableUsersTableProps) {
63  const [sorting, setSorting] = useState<SortingState>([]);
64  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
65  const [globalFilter, setGlobalFilter] = useState('');
66
67  const table = useReactTable({
68    data,
69    columns,
70    state: {
71      sorting,
72      columnFilters,
73      globalFilter,
74    },
75    onSortingChange: setSorting,
76    onColumnFiltersChange: setColumnFilters,
77    onGlobalFilterChange: setGlobalFilter,
78    globalFilterFn,
79    getCoreRowModel: getCoreRowModel(),
80    getSortedRowModel: getSortedRowModel(),
81    getFilteredRowModel: getFilteredRowModel(),
82  });
83

You pick built-in filter functions, such as includesString or equals, by name. The getFilteredRowModel model drops rows that do not match the filters, and sorting keeps working on what is left.

The filter UI is made of ordinary form fields that set values through the table API:

1  return (
2    <div className="space-y-4">
3      {/* Global Search */}
4      <div className="flex items-center space-x-4">
5        <div className="relative flex-1 max-w-sm">
6          <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
7          <input
8            value={globalFilter ?? ''}
9            onChange={(e) => setGlobalFilter(e.target.value)}
10            className="pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 w-full"
11          />
12        </div>
13
14        {/* Roles Filter */}
15        <select
16          value={(table.getColumn('role')?.getFilterValue() as string) ?? ''}
17          onChange={(e) =>
18            table.getColumn('role')?.setFilterValue(e.target.value || undefined)
19          }
20          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
21        >
22          <option value="">All roles</option>
23          <option value="admin">Admin</option>
24          <option value="moderator">Moderator</option>
25          <option value="user">User</option>
26        </select>
27
28        {/* Status Filter */}
29        <select
30          value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
31          onChange={(e) =>
32            table.getColumn('status')?.setFilterValue(e.target.value || undefined)
33          }
34          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
35        >
36          <option value="">All statuses</option>
37          <option value="active">Active</option>
38          <option value="inactive">Inactive</option>
39          <option value="pending">Pending</option>
40        </select>
41      </div>
42
43      {/* Results Info */}
44      <div className="text-sm text-gray-600">
45        Showing {table.getFilteredRowModel().rows.length} of {data.length} users
46      </div>
47
48      {/* Table */}
49      <div className="overflow-x-auto">
50        <table className="min-w-full bg-white border border-gray-200">
51          <thead className="bg-gray-50">
52            {table.getHeaderGroups().map((headerGroup) => (
53              <tr key={headerGroup.id}>
54                {headerGroup.headers.map((header) => (
55                  <th
56                    key={header.id}
57                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
58                  >
59                    <div
60                      className={
61                        header.column.getCanSort()
62                          ? 'flex items-center cursor-pointer select-none hover:text-gray-700'
63                          : ''
64                      }
65                      onClick={header.column.getToggleSortingHandler()}
66                    >
67                      {flexRender(header.column.columnDef.header, header.getContext())}
68                    </div>
69                  </th>
70                ))}
71              </tr>
72            ))}
73          </thead>
74          <tbody className="bg-white divide-y divide-gray-200">
75            {table.getRowModel().rows.map((row) => (
76              <tr key={row.id} className="hover:bg-gray-50">
77                {row.getVisibleCells().map((cell) => (
78                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
79                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
80                  </td>
81                ))}
82              </tr>
83            ))}
84          </tbody>
85        </table>
86      </div>
87    </div>
88  );
89}

In tbody we use getRowModel(), because it returns the final result after filtering and sorting. getFilteredRowModel() would skip sorting, so here it only feeds the result counter.

Pagination

Pagination keeps the page number (pageIndex, counted from zero) and the page size (pageSize) in state. The getPaginationRowModel model slices the rows into pages:

1// components/PaginatedUsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  getPaginationRowModel,
9  getSortedRowModel,
10  getFilteredRowModel,
11  PaginationState,
12  useReactTable,
13} from '@tanstack/react-table';
14import { useState } from 'react';
15import {
16  ChevronLeftIcon,
17  ChevronRightIcon,
18  ChevronDoubleLeftIcon,
19  ChevronDoubleRightIcon
20} from '@heroicons/react/24/outline';
21import { User } from '@/types/User';
22
23const columnHelper = createColumnHelper<User>();
24
25const columns = [
26  columnHelper.accessor('name', {
27    header: 'Username',
28  }),
29  columnHelper.accessor('email', {
30    header: 'Email',
31  }),
32  columnHelper.accessor('role', {
33    header: 'Role',
34    cell: (info) => {
35      const role = info.getValue();
36      return (
37        <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
38          {role}
39        </span>
40      );
41    },
42  }),
43  columnHelper.accessor('status', {
44    header: 'Status',
45    cell: (info) => {
46      const status = info.getValue();
47      const colors = {
48        active: 'bg-green-100 text-green-800',
49        inactive: 'bg-gray-100 text-gray-800',
50        pending: 'bg-yellow-100 text-yellow-800',
51      };
52
53      return (
54        <span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status]}`}>
55          {status}
56        </span>
57      );
58    },
59  }),
60  columnHelper.accessor('createdAt', {
61    header: 'Created at',
62    cell: (info) => new Date(info.getValue()).toLocaleDateString('en-US'),
63  }),
64];
65
66interface PaginatedUsersTableProps {
67  data: User[];
68}
69
70export default function PaginatedUsersTable({ data }: PaginatedUsersTableProps) {
71  const [pagination, setPagination] = useState<PaginationState>({
72    pageIndex: 0,
73    pageSize: 10,
74  });
75
76  const table = useReactTable({
77    data,
78    columns,
79    state: {
80      pagination,
81    },
82    onPaginationChange: setPagination,
83    getCoreRowModel: getCoreRowModel(),
84    getSortedRowModel: getSortedRowModel(),
85    getFilteredRowModel: getFilteredRowModel(),
86    getPaginationRowModel: getPaginationRowModel(),
87  });
88

The table computes the number of pages from the number of rows and pageSize on its own. You do not need to keep a slice of the data in a separate variable.

The navigation controls use ready-made methods such as previousPage(), nextPage() and setPageIndex():

1  return (
2    <div className="space-y-4">
3      {/* Table */}
4      <div className="overflow-x-auto">
5        <table className="min-w-full bg-white border border-gray-200">
6          <thead className="bg-gray-50">
7            {table.getHeaderGroups().map((headerGroup) => (
8              <tr key={headerGroup.id}>
9                {headerGroup.headers.map((header) => (
10                  <th
11                    key={header.id}
12                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
13                  >
14                    {flexRender(header.column.columnDef.header, header.getContext())}
15                  </th>
16                ))}
17              </tr>
18            ))}
19          </thead>
20          <tbody className="bg-white divide-y divide-gray-200">
21            {table.getRowModel().rows.map((row) => (
22              <tr key={row.id} className="hover:bg-gray-50">
23                {row.getVisibleCells().map((cell) => (
24                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
25                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
26                  </td>
27                ))}
28              </tr>
29            ))}
30          </tbody>
31        </table>
32      </div>
33
34      {/* Pagination */}
35      <div className="flex items-center justify-between">
36        <div className="flex items-center space-x-2">
37          <span className="text-sm text-gray-700">
38            Showing{' '}
39            <span className="font-medium">
40              {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
41            </span>{' '}
42            -{' '}
43            <span className="font-medium">
44              {Math.min(
45                (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
46                table.getFilteredRowModel().rows.length
47              )}
48            </span>{' '}
49            of{' '}
50            <span className="font-medium">{table.getFilteredRowModel().rows.length}</span> results
51          </span>
52
53          <select
54            value={table.getState().pagination.pageSize}
55            onChange={(e) => {
56              table.setPageSize(Number(e.target.value));
57            }}
58            className="px-3 py-1 border border-gray-300 rounded text-sm"
59          >
60            {[10, 20, 30, 40, 50].map((pageSize) => (
61              <option key={pageSize} value={pageSize}>
62                {pageSize} na page
63              </option>
64            ))}
65          </select>
66        </div>
67
68        <div className="flex items-center space-x-2">
69          <button
70            onClick={() => table.setPageIndex(0)}
71            disabled={!table.getCanPreviousPage()}
72            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
73          >
74            <ChevronDoubleLeftIcon className="w-4 h-4" />
75          </button>
76
77          <button
78            onClick={() => table.previousPage()}
79            disabled={!table.getCanPreviousPage()}
80            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
81          >
82            <ChevronLeftIcon className="w-4 h-4" />
83          </button>
84
85          <div className="flex items-center space-x-1">
86            <span className="text-sm text-gray-700">Page</span>
87            <input
88              type="number"
89              value={table.getState().pagination.pageIndex + 1}
90              onChange={(e) => {
91                const page = e.target.value ? Number(e.target.value) - 1 : 0;
92                table.setPageIndex(page);
93              }}
94              className="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
95              min={1}
96              max={table.getPageCount()}
97            />
98            <span className="text-sm text-gray-700">z {table.getPageCount()}</span>
99          </div>
100
101          <button
102            onClick={() => table.nextPage()}
103            disabled={!table.getCanNextPage()}
104            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
105          >
106            <ChevronRightIcon className="w-4 h-4" />
107          </button>
108
109          <button
110            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
111            disabled={!table.getCanNextPage()}
112            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
113          >
114            <ChevronDoubleRightIcon className="w-4 h-4" />
115          </button>
116        </div>
117      </div>
118    </div>
119  );
120}

getCanPreviousPage() and getCanNextPage() disable the buttons at the edges. The page number field subtracts 1, because the user counts from one and the table from zero.

Row selection

Selection adds a column of checkboxes. The columnHelper.display method creates a column with no data field, perfect for checkboxes and action buttons:

1// components/SelectableUsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  useReactTable,
9  RowSelectionState,
10} from '@tanstack/react-table';
11import { useState } from 'react';
12import { TrashIcon, UserGroupIcon } from '@heroicons/react/24/outline';
13import { User } from '@/types/User';
14
15const columnHelper = createColumnHelper<User>();
16
17interface SelectableUsersTableProps {
18  data: User[];
19  onDeleteSelected?: (selectedUsers: User[]) => void;
20  onBulkAction?: (action: string, selectedUsers: User[]) => void;
21}
22
23export default function SelectableUsersTable({
24  data,
25  onDeleteSelected,
26  onBulkAction
27}: SelectableUsersTableProps) {
28  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
29
30  const columns = [
31    // Selection column
32    columnHelper.display({
33      id: 'select',
34      header: ({ table }) => (
35        <input
36          type="checkbox"
37          checked={table.getIsAllRowsSelected()}
38          onChange={table.getToggleAllRowsSelectedHandler()}
39          className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
40        />
41      ),
42      cell: ({ row }) => (
43        <input
44          type="checkbox"
45          checked={row.getIsSelected()}
46          onChange={row.getToggleSelectedHandler()}
47          className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
48        />
49      ),
50    }),
51    columnHelper.accessor('name', {
52      header: 'Username',
53    }),
54    columnHelper.accessor('email', {
55      header: 'Email',
56    }),
57    columnHelper.accessor('role', {
58      header: 'Role',
59    }),
60    columnHelper.accessor('status', {
61      header: 'Status',
62    }),
63    // Actions column
64    columnHelper.display({
65      id: 'actions',
66      header: 'Actions',
67      cell: ({ row }) => (
68        <div className="flex space-x-2">
69          <button
70            onClick={() => console.log('Edit user:', row.original)}
71            className="text-blue-600 hover:text-blue-800 text-sm"
72          >
73            Edit
74          </button>
75          <button
76            onClick={() => console.log('Delete user:', row.original)}
77            className="text-red-600 hover:text-red-800 text-sm"
78          >
79            Remove
80          </button>
81        </div>
82      ),
83    }),
84  ];
85
86  const table = useReactTable({
87    data,
88    columns,
89    state: {
90      rowSelection,
91    },
92    onRowSelectionChange: setRowSelection,
93    getCoreRowModel: getCoreRowModel(),
94    enableRowSelection: true,
95  });
96
97  const selectedUsers = table.getFilteredSelectedRowModel().rows.map(row => row.original);
98

The RowSelectionState state is an object keyed by row id. The columns are defined inside the component here, so it is worth wrapping them in useMemo, as the best practices at the end of the lesson explain.

The bulk actions bar appears only when something is selected:

1  return (
2    <div className="space-y-4">
3      {/* Bulk Actions */}
4      {Object.keys(rowSelection).length > 0 && (
5        <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
6          <div className="flex items-center justify-between">
7            <div className="flex items-center space-x-2">
8              <UserGroupIcon className="w-5 h-5 text-blue-600" />
9              <span className="text-sm font-medium text-blue-900">
10                Selected {selectedUsers.length} users
11              </span>
12            </div>
13
14            <div className="flex space-x-2">
15              <button
16                onClick={() => onBulkAction?.('activate', selectedUsers)}
17                className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
18              >
19                Activate
20              </button>
21              <button
22                onClick={() => onBulkAction?.('deactivate', selectedUsers)}
23                className="px-3 py-1 bg-yellow-600 text-white text-sm rounded hover:bg-yellow-700"
24              >
25                Deactivate
26              </button>
27              <button
28                onClick={() => onDeleteSelected?.(selectedUsers)}
29                className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 flex items-center space-x-1"
30              >
31                <TrashIcon className="w-4 h-4" />
32                <span>Remove</span>
33              </button>
34            </div>
35          </div>
36        </div>
37      )}
38
39      {/* Table */}
40      <div className="overflow-x-auto">
41        <table className="min-w-full bg-white border border-gray-200">
42          <thead className="bg-gray-50">
43            {table.getHeaderGroups().map((headerGroup) => (
44              <tr key={headerGroup.id}>
45                {headerGroup.headers.map((header) => (
46                  <th
47                    key={header.id}
48                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
49                  >
50                    {flexRender(header.column.columnDef.header, header.getContext())}
51                  </th>
52                ))}
53              </tr>
54            ))}
55          </thead>
56          <tbody className="bg-white divide-y divide-gray-200">
57            {table.getRowModel().rows.map((row) => (
58              <tr
59                key={row.id}
60                className={`hover:bg-gray-50 ${row.getIsSelected() ? 'bg-blue-50' : ''}`}
61              >
62                {row.getVisibleCells().map((cell) => (
63                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
64                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
65                  </td>
66                ))}
67              </tr>
68            ))}
69          </tbody>
70        </table>
71      </div>
72    </div>
73  );
74}

getFilteredSelectedRowModel() returns the selected rows, and row.original is the original User object. The table does not delete data itself, it passes it to functions from props.

Server-side data fetching

With tens of thousands of records we move sorting and filtering to the server. The order is fixed: an API endpoint, manual sorting and filter state in the table, processing on the server and handling the response in the component. TanStack Query fetches the data:

1// hooks/useUsersData.ts
2import { useQuery, keepPreviousData } from '@tanstack/react-query';
3import { useState } from 'react';
4
5interface UseUsersDataParams {
6  page: number;
7  pageSize: number;
8  sorting?: Array<{ id: string; desc: boolean }>;
9  filters?: Array<{ id: string; value: any }>;
10  globalFilter?: string;
11}
12
13interface UsersResponse {
14  data: User[];
15  totalCount: number;
16  pageCount: number;
17}
18
19export function useUsersData(params: UseUsersDataParams) {
20  return useQuery({
21    queryKey: ['users', params],
22    queryFn: async (): Promise<UsersResponse> => {
23      const searchParams = new URLSearchParams({
24        page: params.page.toString(),
25        pageSize: params.pageSize.toString(),
26        ...(params.globalFilter && { search: params.globalFilter }),
27        ...(params.sorting?.length && {
28          sort: JSON.stringify(params.sorting)
29        }),
30        ...(params.filters?.length && {
31          filters: JSON.stringify(params.filters)
32        }),
33      });
34
35      const response = await fetch(`/api/users?${searchParams}`);
36
37      if (!response.ok) {
38        throw new Error('Failed to fetch users');
39      }
40
41      return response.json();
42    },
43    placeholderData: keepPreviousData,
44  });
45}

queryKey contains the parameters, so every page or sorting change fetches new data. placeholderData: keepPreviousData is the TanStack Query 5 form that shows the previous page until the new one arrives, the older keepPreviousData: true option no longer works.

The component passes the table state to the hook and turns on manual mode with the manualPagination, manualSorting and manualFiltering flags:

1// components/ServerSideUsersTable.tsx
2'use client';
3
4import {
5  createColumnHelper,
6  flexRender,
7  getCoreRowModel,
8  useReactTable,
9  PaginationState,
10  SortingState,
11  ColumnFiltersState,
12} from '@tanstack/react-table';
13import { useState } from 'react';
14import { useUsersData } from '@/hooks/useUsersData';
15import { User } from '@/types/User';
16
17const columnHelper = createColumnHelper<User>();
18
19const columns = [
20  columnHelper.accessor('name', {
21    header: 'Username',
22    enableSorting: true,
23  }),
24  columnHelper.accessor('email', {
25    header: 'Email',
26    enableSorting: true,
27  }),
28  columnHelper.accessor('role', {
29    header: 'Role',
30    enableSorting: true,
31  }),
32  columnHelper.accessor('status', {
33    header: 'Status',
34    enableSorting: true,
35  }),
36  columnHelper.accessor('createdAt', {
37    header: 'Created at',
38    enableSorting: true,
39    cell: (info) => new Date(info.getValue()).toLocaleDateString('en-US'),
40  }),
41];
42
43export default function ServerSideUsersTable() {
44  const [pagination, setPagination] = useState<PaginationState>({
45    pageIndex: 0,
46    pageSize: 10,
47  });
48  const [sorting, setSorting] = useState<SortingState>([]);
49  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
50  const [globalFilter, setGlobalFilter] = useState('');
51
52  const { data, isLoading, error } = useUsersData({
53    page: pagination.pageIndex,
54    pageSize: pagination.pageSize,
55    sorting,
56    filters: columnFilters,
57    globalFilter,
58  });
59
60  const table = useReactTable({
61    data: data?.data ?? [],
62    columns,
63    pageCount: data?.pageCount ?? -1,
64    state: {
65      pagination,
66      sorting,
67      columnFilters,
68      globalFilter,
69    },
70    onPaginationChange: setPagination,
71    onSortingChange: setSorting,
72    onColumnFiltersChange: setColumnFilters,
73    onGlobalFilterChange: setGlobalFilter,
74    getCoreRowModel: getCoreRowModel(),
75    manualPagination: true,
76    manualSorting: true,
77    manualFiltering: true,
78  });
79
80  if (isLoading) {
81    return (
82      <div className="flex items-center justify-center h-64">
83        <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
84      </div>
85    );
86  }
87
88  if (error) {
89    return (
90      <div className="text-center text-red-600 p-8">
91        An error occurred while loading data
92      </div>
93    );
94  }
95

In manual mode the table does not sort or filter by itself, it only reports state changes. pageCount comes from the server, because only the server knows the total number of records.

The view looks almost the same as in the client version:

1  return (
2    <div className="space-y-4">
3      {/* Search */}
4      <div className="flex items-center space-x-4">
5        <input
6          value={globalFilter ?? ''}
7          onChange={(e) => setGlobalFilter(e.target.value)}
8          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 flex-1 max-w-sm"
9        />
10      </div>
11
12      {/* Table */}
13      <div className="overflow-x-auto">
14        <table className="min-w-full bg-white border border-gray-200">
15          <thead className="bg-gray-50">
16            {table.getHeaderGroups().map((headerGroup) => (
17              <tr key={headerGroup.id}>
18                {headerGroup.headers.map((header) => (
19                  <th
20                    key={header.id}
21                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
22                  >
23                    <div
24                      className={
25                        header.column.getCanSort()
26                          ? 'flex items-center cursor-pointer select-none hover:text-gray-700'
27                          : ''
28                      }
29                      onClick={header.column.getToggleSortingHandler()}
30                    >
31                      {flexRender(header.column.columnDef.header, header.getContext())}
32                    </div>
33                  </th>
34                ))}
35              </tr>
36            ))}
37          </thead>
38          <tbody className="bg-white divide-y divide-gray-200">
39            {table.getRowModel().rows.map((row) => (
40              <tr key={row.id} className="hover:bg-gray-50">
41                {row.getVisibleCells().map((cell) => (
42                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
43                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
44                  </td>
45                ))}
46              </tr>
47            ))}
48          </tbody>
49        </table>
50      </div>
51
52      {/* Pagination */}
53      <div className="flex items-center justify-between">
54        <div className="text-sm text-gray-700">
55          Showing {data?.data.length ?? 0} of {data?.totalCount ?? 0} users
56        </div>
57
58        <div className="flex items-center space-x-2">
59          <button
60            onClick={() => table.setPageIndex(0)}
61            disabled={!table.getCanPreviousPage()}
62            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
63          >
64            First
65          </button>
66
67          <button
68            onClick={() => table.previousPage()}
69            disabled={!table.getCanPreviousPage()}
70            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
71          >
72            Previous
73          </button>
74
75          <span className="text-sm">
76            Page {table.getState().pagination.pageIndex + 1} of{' '}
77            {table.getPageCount()}
78          </span>
79
80          <button
81            onClick={() => table.nextPage()}
82            disabled={!table.getCanNextPage()}
83            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
84          >
85            Next
86          </button>
87
88          <button
89            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
90            disabled={!table.getCanNextPage()}
91            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
92          >
93            Last
94          </button>
95        </div>
96      </div>
97    </div>
98  );
99}

The difference is invisible to the user: every page click sends a new request to the API.

API Routes for a server-side table

On the server, a Route Handler reads the parameters from searchParams and does the same work the library did before:

1// app/api/users/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { User } from '@/types/User';
4
5// Simulated data
6const mockUsers: User[] = [
7  // ... user data
8];
9
10export async function GET(request: NextRequest) {
11  const searchParams = request.nextUrl.searchParams;
12
13  const page = parseInt(searchParams.get('page') || '0');
14  const pageSize = parseInt(searchParams.get('pageSize') || '10');
15  const search = searchParams.get('search') || '';
16  const sortParam = searchParams.get('sort');
17  const filtersParam = searchParams.get('filters');
18
19  let filteredUsers = [...mockUsers];
20
21  // Global search
22  if (search) {
23    filteredUsers = filteredUsers.filter(user =>
24      user.name.toLowerCase().includes(search.toLowerCase()) ||
25      user.email.toLowerCase().includes(search.toLowerCase()) ||
26      user.role.toLowerCase().includes(search.toLowerCase())
27    );
28  }
29
30  // Column filters
31  if (filtersParam) {
32    const filters = JSON.parse(filtersParam);
33    filters.forEach((filter: { id: string; value: any }) => {
34      filteredUsers = filteredUsers.filter(user => {
35        const value = user[filter.id as keyof User];
36        return value === filter.value;
37      });
38    });
39  }
40
41  // Sorting
42  if (sortParam) {
43    const sorting = JSON.parse(sortParam);
44    sorting.forEach((sort: { id: string; desc: boolean }) => {
45      filteredUsers.sort((a, b) => {
46        const aVal = a[sort.id as keyof User];
47        const bVal = b[sort.id as keyof User];
48
49        if (aVal < bVal) return sort.desc ? 1 : -1;
50        if (aVal > bVal) return sort.desc ? -1 : 1;
51        return 0;
52      });
53    });
54  }
55
56  // Pagination
57  const totalCount = filteredUsers.length;
58  const pageCount = Math.ceil(totalCount / pageSize);
59  const startIndex = page * pageSize;
60  const endIndex = startIndex + pageSize;
61  const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
62
63  return NextResponse.json({
64    data: paginatedUsers,
65    totalCount,
66    pageCount,
67  });
68}

In order: search, column filters, sorting and cutting out the page. In a real application a database would do these steps with a query using WHERE, ORDER BY and LIMIT.

Responsive tables

A wide table does not fit on a phone. This component shows the table from the md breakpoint up, and on smaller screens cards with expandable details:

1// components/ResponsiveUsersTable.tsx
2'use client';
3
4import { useState } from 'react';
5import { User } from '@/types/User';
6
7interface ResponsiveUsersTableProps {
8  data: User[];
9}
10
11export default function ResponsiveUsersTable({ data }: ResponsiveUsersTableProps) {
12  const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
13
14  const toggleRow = (userId: string) => {
15    const newExpanded = new Set(expandedRows);
16    if (newExpanded.has(userId)) {
17      newExpanded.delete(userId);
18    } else {
19      newExpanded.add(userId);
20    }
21    setExpandedRows(newExpanded);
22  };
23
24  return (
25    <div className="w-full">
26      {/* Desktop Table */}
27      <div className="hidden md:block overflow-x-auto">
28        <table className="min-w-full bg-white border border-gray-200">
29          {/* Standard desktop table */}
30        </table>
31      </div>
32
33      {/* Mobile Cards */}
34      <div className="md:hidden space-y-4">
35        {data.map((user) => (
36          <div key={user.id} className="bg-white border border-gray-200 rounded-lg shadow">
37            <div className="p-4">
38              <div className="flex items-center justify-between">
39                <div className="flex-1">
40                  <h3 className="font-medium text-gray-900">{user.name}</h3>
41                  <p className="text-sm text-gray-600">{user.email}</p>
42                </div>
43
44                <button
45                  onClick={() => toggleRow(user.id)}
46                  className="ml-4 text-blue-600 text-sm"
47                >
48                  {expandedRows.has(user.id) ? 'Less' : 'More'}
49                </button>
50              </div>
51
52              {expandedRows.has(user.id) && (
53                <div className="mt-4 pt-4 border-t border-gray-200">
54                  <div className="grid grid-cols-2 gap-4 text-sm">
55                    <div>
56                      <span className="font-medium text-gray-700">Role:</span>
57                      <span className="ml-1">{user.role}</span>
58                    </div>
59                    <div>
60                      <span className="font-medium text-gray-700">Status:</span>
61                      <span className="ml-1">{user.status}</span>
62                    </div>
63                    <div className="col-span-2">
64                      <span className="font-medium text-gray-700">Created:</span>
65                      <span className="ml-1">
66                        {new Date(user.createdAt).toLocaleDateString('en-US')}
67                      </span>
68                    </div>
69                  </div>
70                </div>
71              )}
72            </div>
73          </div>
74        ))}
75      </div>
76    </div>
77  );
78}

The expansion state is a Set of ids. We create a new Set on every change instead of mutating the old one, so React notices the change.

Data export

Exporting to CSV and JSON needs no library. The hook creates the file as a Blob and downloads it through a temporary link:

1// hooks/useTableExport.ts
2import { User } from '@/types/User';
3
4export function useTableExport() {
5  const exportToCSV = (data: User[], filename = 'users.csv') => {
6    const headers = ['ID', 'Name', 'Email', 'Role', 'Status', 'Created at'];
7    const csvContent = [
8      headers.join(','),
9      ...data.map(user => [
10        user.id,
11        user.name,
12        user.email,
13        user.role,
14        user.status,
15        new Date(user.createdAt).toLocaleDateString('en-US')
16      ].join(','))
17    ].join('\n');
18
19    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
20    const link = document.createElement('a');
21    const url = URL.createObjectURL(blob);
22    link.setAttribute('href', url);
23    link.setAttribute('download', filename);
24    link.style.visibility = 'hidden';
25    document.body.appendChild(link);
26    link.click();
27    document.body.removeChild(link);
28  };
29
30  const exportToJSON = (data: User[], filename = 'users.json') => {
31    const jsonContent = JSON.stringify(data, null, 2);
32    const blob = new Blob([jsonContent], { type: 'application/json' });
33    const link = document.createElement('a');
34    const url = URL.createObjectURL(blob);
35    link.setAttribute('href', url);
36    link.setAttribute('download', filename);
37    link.style.visibility = 'hidden';
38    document.body.appendChild(link);
39    link.click();
40    document.body.removeChild(link);
41  };
42
43  return { exportToCSV, exportToJSON };
44}

CSV rows are joined with a newline character. This simple export does not handle commas inside values, in production wrap such fields in quotes.

Best practices

1. Performance optimization

The table compares data and columns by reference, so new arrays on every render force a recalculation:

1// Good - use memo for columns
2const columns = useMemo(() => [
3  // column definitions
4], []);
5
6// Good - memo for data
7const data = useMemo(() => users, [users]);

useMemo keeps the same reference between renders until the dependencies change.

2. Accessibility

Headless means accessibility is up to you. Use real table elements and ARIA attributes:

1// Good - aria labels
2<table role="table" aria-label="Users table">
3  <thead>
4    <tr role="row">
5      <th role="columnheader" aria-sort="ascending">
6        Name
7      </th>
8    </tr>
9  </thead>
10</table>

aria-sort tells screen readers which column the data is sorted by and in which direction.

3. TypeScript integration

A column helper typed with your data gives you suggestions and checks field names:

1// Good - strong typing
2const columnHelper = createColumnHelper<User>();
3
4const columns = [
5  columnHelper.accessor('name', {
6    // Full IntelliSense support
7  }),
8];

A typo in a field name is a compile error, not an empty column in production.

Summary

TanStack Table with Next.js is a powerful combination for building advanced tables:

  1. Headless UI - full control over styling
  2. TypeScript-first - type safety
  3. Feature-rich - sorting, filtering, pagination
  4. Performance - optimizations, with virtualization from TanStack Virtual
  5. Server-side support - ideal for large datasets

My advice: keep the logic on the client up to a few thousand rows, and above that switch to manual mode with an API. In the next lesson you will learn Typed Routes, which protect your links from typos.

Remember: TanStack Table is the computing engine of the table, and the look of every cell belongs to you.

Code for this lesson: App.tsx
1import React, { useState, useMemo } from 'react';
2
3interface User {
4  id: number;
5  name: string;
6  email: string;
7  role: 'admin' | 'user' | 'moderator';
8  status: 'active' | 'inactive' | 'pending';
9  lastLogin: string;
10}
11
12const mockUsers: User[] = [
13  { id: 1, name: 'Jan Kowalski', email: 'jan@quantum.city', role: 'admin', status: 'active', lastLogin: '2025-03-01' },
14  { id: 2, name: 'Anna Nowak', email: 'anna@quantum.city', role: 'moderator', status: 'active', lastLogin: '2025-02-28' },
15  { id: 3, name: 'Piotr Wisniewski', email: 'piotr@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-25' },
16  { id: 4, name: 'Maria Zielinska', email: 'maria@quantum.city', role: 'user', status: 'active', lastLogin: '2025-03-02' },
17  { id: 5, name: 'Tomasz Dabrowski', email: 'tomasz@quantum.city', role: 'moderator', status: 'inactive', lastLogin: '2025-01-15' },
18  { id: 6, name: 'Katarzyna Lewandowska', email: 'kasia@quantum.city', role: 'user', status: 'active', lastLogin: '2025-03-03' },
19  { id: 7, name: 'Michal Wojcik', email: 'michal@quantum.city', role: 'admin', status: 'active', lastLogin: '2025-03-02' },
20  { id: 8, name: 'Agnieszka Kaminska', email: 'aga@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-20' },
21  { id: 9, name: 'Robert Szymanski', email: 'robert@quantum.city', role: 'user', status: 'inactive', lastLogin: '2024-12-01' },
22  { id: 10, name: 'Ewa Wozniak', email: 'ewa@quantum.city', role: 'moderator', status: 'active', lastLogin: '2025-03-01' },
23];
24
25type SortDir = 'asc' | 'desc' | null;
26
27const statusColors: Record<string, string> = { active: '#4caf50', inactive: '#f44336', pending: '#ff9800' };
28const roleColors: Record<string, string> = { admin: '#7c4dff', moderator: '#64ffda', user: '#888' };
29
30const TanStackTableDemo = () => {
31  const [data] = useState<User[]>(mockUsers);
32  const [sortKey, setSortKey] = useState<keyof User | null>(null);
33  const [sortDir, setSortDir] = useState<SortDir>(null);
34  const [searchQuery, setSearchQuery] = useState('');
35  const [roleFilter, setRoleFilter] = useState<string>('all');
36  const [statusFilter, setStatusFilter] = useState<string>('all');
37  const [page, setPage] = useState(0);
38  const pageSize = 5;
39
40  const handleSort = (key: keyof User) => {
41    if (sortKey === key) {
42      setSortDir(prev => prev === 'asc' ? 'desc' : prev === 'desc' ? null : 'asc');
43      if (sortDir === 'desc') setSortKey(null);
44    } else {
45      setSortKey(key);
46      setSortDir('asc');
47    }
48  };
49
50  const filtered = useMemo(() => {
51    let result = [...data];
52    if (searchQuery) result = result.filter(u => u.name.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()));
53    if (roleFilter !== 'all') result = result.filter(u => u.role === roleFilter);
54    if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
55    if (sortKey && sortDir) {
56      result.sort((a, b) => {
57        const va = a[sortKey], vb = b[sortKey];
58        const cmp = String(va).localeCompare(String(vb));
59        return sortDir === 'asc' ? cmp : -cmp;
60      });
61    }
62    return result;
63  }, [data, searchQuery, roleFilter, statusFilter, sortKey, sortDir]);
64
65  const paged = filtered.slice(page * pageSize, (page + 1) * pageSize);
66  const totalPages = Math.ceil(filtered.length / pageSize);
67
68  const sortIcon = (key: keyof User) => {
69    if (sortKey !== key) return ' ↕';
70    return sortDir === 'asc' ? ' ↑' : ' ↓';
71  };
72
73  return (
74    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
75      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Next.js + TanStack Table</h1>
76      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>Sorting, Filtering, Pagination</p>
77
78      <div style={{ maxWidth: '900px', margin: '0 auto' }}>
79        <div style={{ display: 'flex', gap: '8px', marginBottom: '16px', flexWrap: 'wrap' }}>
80          <input value={searchQuery} onChange={e => { setSearchQuery(e.target.value); setPage(0); }} placeholder="Search by name or email..."
81            style={{ flex: 1, minWidth: '200px', padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
82          <select value={roleFilter} onChange={e => { setRoleFilter(e.target.value); setPage(0); }}
83            style={{ padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }}>
84            <option value="all">All Roles</option>
85            <option value="admin">Admin</option>
86            <option value="moderator">Moderator</option>
87            <option value="user">User</option>
88          </select>
89          <select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(0); }}
90            style={{ padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }}>
91            <option value="all">All Statuses</option>
92            <option value="active">Active</option>
93            <option value="inactive">Inactive</option>
94            <option value="pending">Pending</option>
95          </select>
96        </div>
97
98        <div style={{ overflowX: 'auto', borderRadius: '10px', border: '1px solid #333' }}>
99          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
100            <thead>
101              <tr style={{ background: '#1a1a2e' }}>
102                {[
103                  { key: 'name' as keyof User, label: 'Name' },
104                  { key: 'email' as keyof User, label: 'Email' },
105                  { key: 'role' as keyof User, label: 'Role' },
106                  { key: 'status' as keyof User, label: 'Status' },
107                  { key: 'lastLogin' as keyof User, label: 'Last Login' },
108                ].map(col => (
109                  <th key={col.key} onClick={() => handleSort(col.key)}
110                    style={{ padding: '12px', textAlign: 'left', color: '#64ffda', cursor: 'pointer', borderBottom: '2px solid #333', userSelect: 'none', whiteSpace: 'nowrap' }}>
111                    {col.label}{sortIcon(col.key)}
112                  </th>
113                ))}
114              </tr>
115            </thead>
116            <tbody>
117              {paged.map(user => (
118                <tr key={user.id} style={{ borderBottom: '1px solid #1a1a2e' }}>
119                  <td style={{ padding: '10px 12px' }}>{user.name}</td>
120                  <td style={{ padding: '10px 12px', color: '#7c4dff' }}>{user.email}</td>
121                  <td style={{ padding: '10px 12px' }}>
122                    <span style={{ background: roleColors[user.role] + '33', color: roleColors[user.role], padding: '3px 10px', borderRadius: '12px', fontSize: '12px' }}>{user.role}</span>
123                  </td>
124                  <td style={{ padding: '10px 12px' }}>
125                    <span style={{ color: statusColors[user.status], fontWeight: 'bold', fontSize: '13px' }}>{user.status}</span>
126                  </td>
127                  <td style={{ padding: '10px 12px', color: '#888' }}>{user.lastLogin}</td>
128                </tr>
129              ))}
130              {paged.length === 0 && (
131                <tr><td colSpan={5} style={{ textAlign: 'center', padding: '30px', color: '#555' }}>No results found</td></tr>
132              )}
133            </tbody>
134          </table>
135        </div>
136
137        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px' }}>
138          <span style={{ color: '#888', fontSize: '13px' }}>Showing {paged.length} of {filtered.length} users</span>
139          <div style={{ display: 'flex', gap: '6px' }}>
140            <button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
141              style={{ padding: '6px 12px', background: page === 0 ? '#222' : '#7c4dff', border: 'none', borderRadius: '4px', color: '#fff', cursor: page === 0 ? 'default' : 'pointer' }}>Prev</button>
142            <span style={{ padding: '6px 12px', color: '#888' }}>{page + 1} / {totalPages || 1}</span>
143            <button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
144              style={{ padding: '6px 12px', background: page >= totalPages - 1 ? '#222' : '#7c4dff', border: 'none', borderRadius: '4px', color: '#fff', cursor: page >= totalPages - 1 ? 'default' : 'pointer' }}>Next</button>
145          </div>
146        </div>
147      </div>
148    </div>
149  );
150};
151
152export default TanStackTableDemo;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. TanStack Table offers:

  2. 2. Which feature is NOT built into TanStack Table?

Hands-on tasks in the game

  • Code editor

    Implement a data table with sorting and filtering using TanStack Table

  • Code editor

    Create a table with columns and a data source.

Useful articles