Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje
Next.js z TanStack Table - Zaawansowane tabele danych
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-corePakiet @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];
66Kolumny 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 });
83Wbudowane 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 });
88Tabela 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);
98Stan 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 }
95W 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:
- Headless UI - pełna kontrola nad stylingiem
- TypeScript-first - bezpieczeństwo typów
- Feature-rich - sortowanie, filtrowanie, paginacja
- Performance - optymalizacje, a wirtualizacja z TanStack Virtual
- 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. TanStack Table oferuje:
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.