Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje

Next.js z TanStack Table - Zaawansowane tabele danych

21 min czytania
W tej lekcji11

Panel strażników Metropolii Quantum pokazuje tysiące rekordów: użytkowników, role, statusy. Zwykły element table z pętlą map wystarcza przez tydzień, a potem ktoś prosi o sortowanie, wyszukiwarkę, paginację i zaznaczanie wielu wierszy naraz. Pisanie tego ręcznie to setki linii kruchej logiki. Matrix, architekt danych Metropolii, rozwiązuje to TanStack Table - biblioteką, która liczy logikę tabeli, a wygląd zostawia Tobie.

Czym jest TanStack Table?

TanStack Table (wcześniej React Table) to headless biblioteka do budowania tabel, która oferuje:

  • Headless UI - pełna kontrola nad wyglądem
  • TypeScript-first - pełne wsparcie typowania
  • Framework agnostic - działa z React, Vue, Svelte
  • Feature-rich - sortowanie, filtrowanie, paginacja, grouping
  • Performance - optymalizacje dla dużych zbiorów danych
  • Extensible - bogaty system pluginów

"Headless" oznacza, że biblioteka nie renderuje żadnego HTML-a. Dostajesz API ze stanem i metodami, a znaczniki i style piszesz sam.

Zalety TanStack Table

  • Brak zależności UI - żadnych gotowych komponentów ani stylów
  • Tree-shakable - tylko potrzebne funkcje w bundlu
  • Server-side support - idealny dla SSR/SSG
  • Accessibility - dostępność zależy od Twojego HTML-a, bo to Ty go piszesz
  • Mobile-friendly - responsywne tabele

Czego TanStack Table NIE robi: nie pobiera danych i nie ma wbudowanej wirtualizacji. Dane dostarczasz sam (np. przez TanStack Query), a wirtualizację długich list zapewnia osobna biblioteka TanStack Virtual.

Instalacja i podstawowa konfiguracja

Instalacja

W projekcie React instalujesz adapter @tanstack/react-table, który zawiera już rdzeń biblioteki:

1# Zainstaluj TanStack Table
2npm install @tanstack/react-table
3
4# Rdzeń niezależny od frameworka (react-table instaluje go automatycznie)
5npm install @tanstack/table-core

Pakiet @tanstack/table-core to rdzeń niezależny od frameworka. Adapter React instaluje go automatycznie, więc osobna instalacja jest opcjonalna.

Podstawowa tabela

Każda tabela zaczyna się od typu danych. Interfejs User opisuje jeden wiersz, a unie literałów, jak 'admin' | 'user' | 'moderator', ograniczają dozwolone wartości:

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}

Na tej podstawie TypeScript podpowie nazwy kolumn i złapie literówki w kluczach.

Teraz definiujemy kolumny. Funkcja createColumnHelper<User>() tworzy pomocnika, którego metoda accessor wskazuje pole obiektu, a cell decyduje, jak wyrenderować wartość:

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: 'Nazwa użytkownika',
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: 'Rola',
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: 'Data utworzenia',
63    cell: (info) => new Date(info.getValue()).toLocaleDateString('pl-PL'),
64  }),
65];
66

Kolumny to zwykła tablica konfiguracji, jeszcze bez żadnej tabeli. Rola i status dostają kolorowe etykiety, a data jest formatowana dla polskiej lokalizacji.

Komponent łączy dane z kolumnami hookiem useReactTable. Minimalna konfiguracja to data, columns i getCoreRowModel, czyli podstawowy model wierszy:

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 renderuje nagłówek lub komórkę niezależnie od tego, czy w definicji jest tekst, czy funkcja. Cały HTML i klasy Tailwind są nasze: biblioteka dała tylko grupy nagłówków i wiersze.

Sortowanie i filtrowanie

Sortowanie

Każda funkcja tabeli działa według tego samego schematu: stan w useState, podpięcie go przez state i on...Change oraz dodanie odpowiedniego modelu wierszy. Dla sortowania to SortingState i 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: 'Nazwa użytkownika',
21    enableSorting: true,
22  }),
23  columnHelper.accessor('email', {
24    header: 'Email',
25    enableSorting: true,
26  }),
27  columnHelper.accessor('role', {
28    header: 'Rola',
29    enableSorting: true,
30  }),
31  columnHelper.accessor('status', {
32    header: 'Status',
33    enableSorting: true,
34  }),
35  columnHelper.accessor('createdAt', {
36    header: 'Data utworzenia',
37    enableSorting: true,
38    sortingFn: 'datetime',
39    cell: (info) => new Date(info.getValue()).toLocaleDateString('pl-PL'),
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}

Kliknięcie nagłówka woła getToggleSortingHandler(), który przełącza kolejność: rosnąco, malejąco, bez sortowania. sortingFn: 'datetime' sortuje daty chronologicznie, a nie alfabetycznie.

Filtrowanie

Filtrowanie dodaje dwa rodzaje stanu: filtry kolumn (ColumnFiltersState) i jeden filtr globalny dla wyszukiwarki. Najpierw konfiguracja z własną funkcją globalFilterFn:

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: 'Nazwa użytkownika',
34    enableColumnFilter: true,
35    filterFn: 'includesString',
36  }),
37  columnHelper.accessor('email', {
38    header: 'Email',
39    enableColumnFilter: true,
40  }),
41  columnHelper.accessor('role', {
42    header: 'Rola',
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: 'Data utworzenia',
53    enableColumnFilter: false,
54    cell: (info) => new Date(info.getValue()).toLocaleDateString('pl-PL'),
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

Wbudowane funkcje filtrów, jak includesString czy equals, wybierasz nazwą. Model getFilteredRowModel odrzuca wiersze niepasujące do filtrów, a sortowanie działa dalej na tym, co zostało.

Interfejs filtrów to zwykłe pola formularza, które ustawiają wartości przez API tabeli:

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        {/* Role 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="">Wszystkie role</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="">Wszystkie statusy</option>
37          <option value="active">Aktywny</option>
38          <option value="inactive">Nieaktywny</option>
39          <option value="pending">Oczekujący</option>
40        </select>
41      </div>
42
43      {/* Results Info */}
44      <div className="text-sm text-gray-600">
45        Wyświetlono {table.getFilteredRowModel().rows.length} z {data.length} użytkowników
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}

W tbody używamy getRowModel(), bo zwraca końcowy wynik po filtrowaniu i sortowaniu. getFilteredRowModel() pominąłby sortowanie, dlatego służy tu tylko do licznika wyników.

Paginacja

Paginacja trzyma w stanie numer strony (pageIndex, liczony od zera) i jej rozmiar (pageSize). Model getPaginationRowModel tnie wiersze na strony:

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: 'Nazwa użytkownika',
28  }),
29  columnHelper.accessor('email', {
30    header: 'Email',
31  }),
32  columnHelper.accessor('role', {
33    header: 'Rola',
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: 'Data utworzenia',
62    cell: (info) => new Date(info.getValue()).toLocaleDateString('pl-PL'),
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

Tabela sama liczy liczbę stron na podstawie liczby wierszy i pageSize. Nie musisz trzymać wycinka danych w osobnej zmiennej.

Kontrolki nawigacji korzystają z gotowych metod, jak previousPage(), nextPage() i 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            Wyświetlono{' '}
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            z{' '}
50            <span className="font-medium">{table.getFilteredRowModel().rows.length}</span> wyników
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 stronę
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">Strona</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() i getCanNextPage() blokują przyciski na krańcach. Pole z numerem strony odejmuje 1, bo użytkownik liczy od jedynki, a tabela od zera.

Selekcja wierszy

Selekcja dodaje kolumnę z checkboxami. Metoda columnHelper.display tworzy kolumnę bez pola danych, idealną na checkboxy i przyciski akcji:

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: 'Nazwa użytkownika',
53    }),
54    columnHelper.accessor('email', {
55      header: 'Email',
56    }),
57    columnHelper.accessor('role', {
58      header: 'Rola',
59    }),
60    columnHelper.accessor('status', {
61      header: 'Status',
62    }),
63    // Actions column
64    columnHelper.display({
65      id: 'actions',
66      header: 'Akcje',
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            Edytuj
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            Usuń
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

Stan RowSelectionState to obiekt, w którym kluczem jest id wiersza. Kolumny są tu zdefiniowane w komponencie, więc warto je owinąć w useMemo, o czym mówią dobre praktyki na końcu lekcji.

Pasek akcji zbiorczych pojawia się tylko wtedy, gdy coś jest zaznaczone:

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                Zaznaczono {selectedUsers.length} użytkowników
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                Aktywuj
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                Dezaktywuj
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>Usuń</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() zwraca zaznaczone wiersze, a row.original to oryginalny obiekt User. Tabela nie usuwa danych sama, przekazuje je do funkcji z propsów.

Server-side data fetching

Przy dziesiątkach tysięcy rekordów sortowanie i filtrowanie przenosimy na serwer. Kolejność jest stała: endpoint API, ręczny stan sortowania i filtrów w tabeli, przetwarzanie na serwerze i obsługa odpowiedzi w komponencie. Dane pobiera TanStack Query:

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 zawiera parametry, więc każda zmiana strony czy sortowania pobiera nowe dane. placeholderData: keepPreviousData to zapis z TanStack Query 5, który pokazuje poprzednią stronę do czasu nadejścia nowej, starsza opcja keepPreviousData: true już nie działa.

Komponent przekazuje stan tabeli do hooka i włącza tryb ręczny flagami manualPagination, manualSorting i manualFiltering:

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: 'Nazwa użytkownika',
22    enableSorting: true,
23  }),
24  columnHelper.accessor('email', {
25    header: 'Email',
26    enableSorting: true,
27  }),
28  columnHelper.accessor('role', {
29    header: 'Rola',
30    enableSorting: true,
31  }),
32  columnHelper.accessor('status', {
33    header: 'Status',
34    enableSorting: true,
35  }),
36  columnHelper.accessor('createdAt', {
37    header: 'Data utworzenia',
38    enableSorting: true,
39    cell: (info) => new Date(info.getValue()).toLocaleDateString('pl-PL'),
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        Wystąpił błąd podczas ładowania danych
92      </div>
93    );
94  }
95

W trybie ręcznym tabela nie sortuje ani nie filtruje sama, tylko zgłasza zmiany stanu. pageCount pochodzi z serwera, bo tylko serwer zna liczbę wszystkich rekordów.

Widok wygląda prawie tak samo jak w wersji klienckiej:

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          Wyświetlono {data?.data.length ?? 0} z {data?.totalCount ?? 0} użytkowników
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            Pierwsza
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            Poprzednia
73          </button>
74
75          <span className="text-sm">
76            Strona {table.getState().pagination.pageIndex + 1} z{' '}
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            Następna
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            Ostatnia
94          </button>
95        </div>
96      </div>
97    </div>
98  );
99}

Różnica jest niewidoczna dla użytkownika: każde kliknięcie strony wysyła nowe zapytanie do API.

API Routes dla server-side table

Po stronie serwera Route Handler czyta parametry z searchParams i wykonuje tę samą pracę co wcześniej biblioteka:

1// app/api/users/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { User } from '@/types/User';
4
5// Symulowane dane
6const mockUsers: User[] = [
7  // ... dane użytkowników
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}

Kolejno: wyszukiwanie, filtry kolumn, sortowanie i wycięcie strony. W prawdziwej aplikacji te kroki wykonałaby baza danych zapytaniem z WHERE, ORDER BY i LIMIT.

Responsywne tabele

Na telefonie szeroka tabela się nie mieści. Ten komponent pokazuje tabelę od punktu md, a na mniejszych ekranach karty z rozwijanymi szczegółami:

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          {/* Standardowa tabela dla desktop */}
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) ? 'Mniej' : 'Więcej'}
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">Rola:</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">Utworzono:</span>
65                      <span className="ml-1">
66                        {new Date(user.createdAt).toLocaleDateString('pl-PL')}
67                      </span>
68                    </div>
69                  </div>
70                </div>
71              )}
72            </div>
73          </div>
74        ))}
75      </div>
76    </div>
77  );
78}

Stan rozwinięcia to Set identyfikatorów. Tworzymy nowy Set przy każdej zmianie, zamiast mutować stary, żeby React zauważył zmianę.

Eksport danych

Eksport do CSV i JSON nie wymaga biblioteki. Hook tworzy plik jako Blob i pobiera go przez tymczasowy 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', 'Nazwa', 'Email', 'Rola', 'Status', 'Data utworzenia'];
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('pl-PL')
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}

Wiersze CSV łączymy znakiem nowej linii. Ten prosty eksport nie obsługuje przecinków wewnątrz wartości, w produkcji ujmij takie pola w cudzysłowy.

Najlepsze praktyki

1. Performance optimization

Tabela porównuje data i columns przez referencję, więc nowe tablice przy każdym renderze wymuszają ponowne przeliczenie:

1// Dobrze - używaj memo dla kolumn
2const columns = useMemo(() => [
3  // definicje kolumn
4], []);
5
6// Dobrze - memo dla data
7const data = useMemo(() => users, [users]);

useMemo utrzymuje tę samą referencję między renderami, dopóki zależności się nie zmienią.

2. Accessibility

Headless oznacza, że dostępność zależy od Ciebie. Używaj prawdziwych znaczników tabeli i atrybutów ARIA:

1// Dobrze - aria labels
2<table role="table" aria-label="Tabela użytkowników">
3  <thead>
4    <tr role="row">
5      <th role="columnheader" aria-sort="ascending">
6        Nazwa
7      </th>
8    </tr>
9  </thead>
10</table>

aria-sort mówi czytnikom ekranu, po której kolumnie i w jakim kierunku posortowano dane.

3. TypeScript integration

Pomocnik kolumn z typem danych daje podpowiedzi i sprawdzanie nazw pól:

1// Dobrze - silne typowanie
2const columnHelper = createColumnHelper<User>();
3
4const columns = [
5  columnHelper.accessor('name', {
6    // Pełne wsparcie IntelliSense
7  }),
8];

Literówka w nazwie pola to błąd kompilacji, a nie pusta kolumna w produkcji.

Podsumowanie

TanStack Table z Next.js tworzy potężną kombinację do budowania zaawansowanych tabel:

  1. Headless UI - pełna kontrola nad stylingiem
  2. TypeScript-first - bezpieczeństwo typów
  3. Feature-rich - sortowanie, filtrowanie, paginacja
  4. Performance - optymalizacje, a wirtualizacja z TanStack Virtual
  5. Server-side support - idealne dla dużych zbiorów danych

Moja rada: do kilku tysięcy wierszy trzymaj logikę po stronie klienta, a powyżej przełącz się na tryb ręczny z API. W następnej lekcji poznasz Typed Routes, które chronią linki przed literówkami.

Zapamiętaj: TanStack Table to silnik obliczeniowy tabeli, a wygląd każdej komórki należy do Ciebie.

Kod do tej lekcji: 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 Wiśniewski', email: 'piotr@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-25' },
16  { id: 4, name: 'Maria Zielińska', email: 'maria@quantum.city', role: 'user', status: 'active', lastLogin: '2025-03-02' },
17  { id: 5, name: 'Tomasz Dąbrowski', 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: 'Michał Wójcik', email: 'michal@quantum.city', role: 'admin', status: 'active', lastLogin: '2025-03-02' },
20  { id: 8, name: 'Agnieszka Kamińska', email: 'aga@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-20' },
21  { id: 9, name: 'Robert Szymański', email: 'robert@quantum.city', role: 'user', status: 'inactive', lastLogin: '2024-12-01' },
22  { id: 10, name: 'Ewa Woźniak', 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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. TanStack Table oferuje:

  2. 2. Która funkcja NIE jest wbudowana w TanStack Table?

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj tabelę danych z sortowaniem i filtrowaniem używając TanStack Table

  • Edytor kodu

    Stwórz tabelę z kolumnami i data source.

Przydatne artykuły