Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds

Next.js z TanStack Table - Zaawansowane tabele danych

W Metropolii Quantum, gdzie dane przepływają jak strumienie kwantowej informacji, poznasz TanStack Table - najpotężniejszą bibliotekę do tworzenia zaawansowanych tabel w React. Ta kombinacja z Next.js pozwala budować interaktywne, wydajne interfejsy do zarządzania dużymi zbiorami danych.

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 - wirtualizacja i optymalizacje
  • Extensible - bogaty system pluginów

Zalety TanStack Table

  • Zero dependencies - nie wymaga dodatkowych bibliotek
  • Tree-shakable - tylko potrzebne funkcje w bundlu
  • Server-side support - idealny dla SSR/SSG
  • Accessibility - wbudowane wsparcie a11y
  • Mobile-friendly - responsywne tabele

Instalacja i podstawowa konfiguracja

Instalacja

1# Zainstaluj TanStack Table
2npm install @tanstack/react-table
3
4# Dla stylowania (opcjonalne)
5npm install @tanstack/table-core

Podstawowa tabela

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}
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
67interface UsersTableProps {
68  data: User[];
69}
70
71export default function UsersTable({ data }: UsersTableProps) {
72  const table = useReactTable({
73    data,
74    columns,
75    getCoreRowModel: getCoreRowModel(),
76  });
77
78  return (
79    <div className="overflow-x-auto">
80      <table className="min-w-full bg-white border border-gray-200">
81        <thead className="bg-gray-50">
82          {table.getHeaderGroups().map((headerGroup) => (
83            <tr key={headerGroup.id}>
84              {headerGroup.headers.map((header) => (
85                <th
86                  key={header.id}
87                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
88                >
89                  {header.isPlaceholder
90                    ? null
91                    : flexRender(header.column.columnDef.header, header.getContext())}
92                </th>
93              ))}
94            </tr>
95          ))}
96        </thead>
97        <tbody className="bg-white divide-y divide-gray-200">
98          {table.getRowModel().rows.map((row) => (
99            <tr key={row.id} className="hover:bg-gray-50">
100              {row.getVisibleCells().map((cell) => (
101                <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
102                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
103                </td>
104              ))}
105            </tr>
106          ))}
107        </tbody>
108      </table>
109    </div>
110  );
111}

Sortowanie i filtrowanie

Sortowanie

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}

Filtrowanie

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
84  return (
85    <div className="space-y-4">
86      {/* Global Search */}
87      <div className="flex items-center space-x-4">
88        <div className="relative flex-1 max-w-sm">
89          <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
90          <input
91            value={globalFilter ?? ''}
92            onChange={(e) => setGlobalFilter(e.target.value)}
93            className="pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 w-full"
94          />
95        </div>
96        
97        {/* Role Filter */}
98        <select
99          value={(table.getColumn('role')?.getFilterValue() as string) ?? ''}
100          onChange={(e) =>
101            table.getColumn('role')?.setFilterValue(e.target.value || undefined)
102          }
103          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
104        >
105          <option value="">Wszystkie role</option>
106          <option value="admin">Admin</option>
107          <option value="moderator">Moderator</option>
108          <option value="user">User</option>
109        </select>
110
111        {/* Status Filter */}
112        <select
113          value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
114          onChange={(e) =>
115            table.getColumn('status')?.setFilterValue(e.target.value || undefined)
116          }
117          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
118        >
119          <option value="">Wszystkie statusy</option>
120          <option value="active">Aktywny</option>
121          <option value="inactive">Nieaktywny</option>
122          <option value="pending">Oczekujący</option>
123        </select>
124      </div>
125
126      {/* Results Info */}
127      <div className="text-sm text-gray-600">
128        Wyświetlono {table.getFilteredRowModel().rows.length} z {data.length} użytkowników
129      </div>
130
131      {/* Table */}
132      <div className="overflow-x-auto">
133        <table className="min-w-full bg-white border border-gray-200">
134          <thead className="bg-gray-50">
135            {table.getHeaderGroups().map((headerGroup) => (
136              <tr key={headerGroup.id}>
137                {headerGroup.headers.map((header) => (
138                  <th
139                    key={header.id}
140                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
141                  >
142                    <div
143                      className={
144                        header.column.getCanSort()
145                          ? 'flex items-center cursor-pointer select-none hover:text-gray-700'
146                          : ''
147                      }
148                      onClick={header.column.getToggleSortingHandler()}
149                    >
150                      {flexRender(header.column.columnDef.header, header.getContext())}
151                    </div>
152                  </th>
153                ))}
154              </tr>
155            ))}
156          </thead>
157          <tbody className="bg-white divide-y divide-gray-200">
158            {table.getFilteredRowModel().rows.map((row) => (
159              <tr key={row.id} className="hover:bg-gray-50">
160                {row.getVisibleCells().map((cell) => (
161                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
162                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
163                  </td>
164                ))}
165              </tr>
166            ))}
167          </tbody>
168        </table>
169      </div>
170    </div>
171  );
172}

Paginacja

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
89  return (
90    <div className="space-y-4">
91      {/* Table */}
92      <div className="overflow-x-auto">
93        <table className="min-w-full bg-white border border-gray-200">
94          <thead className="bg-gray-50">
95            {table.getHeaderGroups().map((headerGroup) => (
96              <tr key={headerGroup.id}>
97                {headerGroup.headers.map((header) => (
98                  <th
99                    key={header.id}
100                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
101                  >
102                    {flexRender(header.column.columnDef.header, header.getContext())}
103                  </th>
104                ))}
105              </tr>
106            ))}
107          </thead>
108          <tbody className="bg-white divide-y divide-gray-200">
109            {table.getRowModel().rows.map((row) => (
110              <tr key={row.id} className="hover:bg-gray-50">
111                {row.getVisibleCells().map((cell) => (
112                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
113                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
114                  </td>
115                ))}
116              </tr>
117            ))}
118          </tbody>
119        </table>
120      </div>
121
122      {/* Pagination */}
123      <div className="flex items-center justify-between">
124        <div className="flex items-center space-x-2">
125          <span className="text-sm text-gray-700">
126            Wyświetlono{' '}
127            <span className="font-medium">
128              {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
129            </span>{' '}
130            -{' '}
131            <span className="font-medium">
132              {Math.min(
133                (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
134                table.getFilteredRowModel().rows.length
135              )}
136            </span>{' '}
137            z{' '}
138            <span className="font-medium">{table.getFilteredRowModel().rows.length}</span> wyników
139          </span>
140          
141          <select
142            value={table.getState().pagination.pageSize}
143            onChange={(e) => {
144              table.setPageSize(Number(e.target.value));
145            }}
146            className="px-3 py-1 border border-gray-300 rounded text-sm"
147          >
148            {[10, 20, 30, 40, 50].map((pageSize) => (
149              <option key={pageSize} value={pageSize}>
150                {pageSize} na stronę
151              </option>
152            ))}
153          </select>
154        </div>
155
156        <div className="flex items-center space-x-2">
157          <button
158            onClick={() => table.setPageIndex(0)}
159            disabled={!table.getCanPreviousPage()}
160            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
161          >
162            <ChevronDoubleLeftIcon className="w-4 h-4" />
163          </button>
164          
165          <button
166            onClick={() => table.previousPage()}
167            disabled={!table.getCanPreviousPage()}
168            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
169          >
170            <ChevronLeftIcon className="w-4 h-4" />
171          </button>
172
173          <div className="flex items-center space-x-1">
174            <span className="text-sm text-gray-700">Strona</span>
175            <input
176              type="number"
177              value={table.getState().pagination.pageIndex + 1}
178              onChange={(e) => {
179                const page = e.target.value ? Number(e.target.value) - 1 : 0;
180                table.setPageIndex(page);
181              }}
182              className="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
183              min={1}
184              max={table.getPageCount()}
185            />
186            <span className="text-sm text-gray-700">z {table.getPageCount()}</span>
187          </div>
188
189          <button
190            onClick={() => table.nextPage()}
191            disabled={!table.getCanNextPage()}
192            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
193          >
194            <ChevronRightIcon className="w-4 h-4" />
195          </button>
196          
197          <button
198            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
199            disabled={!table.getCanNextPage()}
200            className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
201          >
202            <ChevronDoubleRightIcon className="w-4 h-4" />
203          </button>
204        </div>
205      </div>
206    </div>
207  );
208}

Selekcja wierszy

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
99  return (
100    <div className="space-y-4">
101      {/* Bulk Actions */}
102      {Object.keys(rowSelection).length > 0 && (
103        <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
104          <div className="flex items-center justify-between">
105            <div className="flex items-center space-x-2">
106              <UserGroupIcon className="w-5 h-5 text-blue-600" />
107              <span className="text-sm font-medium text-blue-900">
108                Zaznaczono {selectedUsers.length} użytkowników
109              </span>
110            </div>
111            
112            <div className="flex space-x-2">
113              <button
114                onClick={() => onBulkAction?.('activate', selectedUsers)}
115                className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
116              >
117                Aktywuj
118              </button>
119              <button
120                onClick={() => onBulkAction?.('deactivate', selectedUsers)}
121                className="px-3 py-1 bg-yellow-600 text-white text-sm rounded hover:bg-yellow-700"
122              >
123                Dezaktywuj
124              </button>
125              <button
126                onClick={() => onDeleteSelected?.(selectedUsers)}
127                className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 flex items-center space-x-1"
128              >
129                <TrashIcon className="w-4 h-4" />
130                <span>Usuń</span>
131              </button>
132            </div>
133          </div>
134        </div>
135      )}
136
137      {/* Table */}
138      <div className="overflow-x-auto">
139        <table className="min-w-full bg-white border border-gray-200">
140          <thead className="bg-gray-50">
141            {table.getHeaderGroups().map((headerGroup) => (
142              <tr key={headerGroup.id}>
143                {headerGroup.headers.map((header) => (
144                  <th
145                    key={header.id}
146                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
147                  >
148                    {flexRender(header.column.columnDef.header, header.getContext())}
149                  </th>
150                ))}
151              </tr>
152            ))}
153          </thead>
154          <tbody className="bg-white divide-y divide-gray-200">
155            {table.getRowModel().rows.map((row) => (
156              <tr 
157                key={row.id} 
158                className={`hover:bg-gray-50 ${row.getIsSelected() ? 'bg-blue-50' : ''}`}
159              >
160                {row.getVisibleCells().map((cell) => (
161                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
162                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
163                  </td>
164                ))}
165              </tr>
166            ))}
167          </tbody>
168        </table>
169      </div>
170    </div>
171  );
172}

Server-side data fetching

1// hooks/useUsersData.ts
2import { useQuery } 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    keepPreviousData: true,
44  });
45}
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
96  return (
97    <div className="space-y-4">
98      {/* Search */}
99      <div className="flex items-center space-x-4">
100        <input
101          value={globalFilter ?? ''}
102          onChange={(e) => setGlobalFilter(e.target.value)}
103          className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 flex-1 max-w-sm"
104        />
105      </div>
106
107      {/* Table */}
108      <div className="overflow-x-auto">
109        <table className="min-w-full bg-white border border-gray-200">
110          <thead className="bg-gray-50">
111            {table.getHeaderGroups().map((headerGroup) => (
112              <tr key={headerGroup.id}>
113                {headerGroup.headers.map((header) => (
114                  <th
115                    key={header.id}
116                    className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
117                  >
118                    <div
119                      className={
120                        header.column.getCanSort()
121                          ? 'flex items-center cursor-pointer select-none hover:text-gray-700'
122                          : ''
123                      }
124                      onClick={header.column.getToggleSortingHandler()}
125                    >
126                      {flexRender(header.column.columnDef.header, header.getContext())}
127                    </div>
128                  </th>
129                ))}
130              </tr>
131            ))}
132          </thead>
133          <tbody className="bg-white divide-y divide-gray-200">
134            {table.getRowModel().rows.map((row) => (
135              <tr key={row.id} className="hover:bg-gray-50">
136                {row.getVisibleCells().map((cell) => (
137                  <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
138                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
139                  </td>
140                ))}
141              </tr>
142            ))}
143          </tbody>
144        </table>
145      </div>
146
147      {/* Pagination */}
148      <div className="flex items-center justify-between">
149        <div className="text-sm text-gray-700">
150          Wyświetlono {data?.data.length ?? 0} z {data?.totalCount ?? 0} użytkowników
151        </div>
152
153        <div className="flex items-center space-x-2">
154          <button
155            onClick={() => table.setPageIndex(0)}
156            disabled={!table.getCanPreviousPage()}
157            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
158          >
159            Pierwsza
160          </button>
161          
162          <button
163            onClick={() => table.previousPage()}
164            disabled={!table.getCanPreviousPage()}
165            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
166          >
167            Poprzednia
168          </button>
169
170          <span className="text-sm">
171            Strona {table.getState().pagination.pageIndex + 1} z{' '}
172            {table.getPageCount()}
173          </span>
174
175          <button
176            onClick={() => table.nextPage()}
177            disabled={!table.getCanNextPage()}
178            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
179          >
180            Następna
181          </button>
182          
183          <button
184            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
185            disabled={!table.getCanNextPage()}
186            className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
187          >
188            Ostatnia
189          </button>
190        </div>
191      </div>
192    </div>
193  );
194}

API Routes dla server-side table

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}

Responsywne tabele

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}

Eksport danych

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('
18');
19
20    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
21    const link = document.createElement('a');
22    const url = URL.createObjectURL(blob);
23    link.setAttribute('href', url);
24    link.setAttribute('download', filename);
25    link.style.visibility = 'hidden';
26    document.body.appendChild(link);
27    link.click();
28    document.body.removeChild(link);
29  };
30
31  const exportToJSON = (data: User[], filename = 'users.json') => {
32    const jsonContent = JSON.stringify(data, null, 2);
33    const blob = new Blob([jsonContent], { type: 'application/json' });
34    const link = document.createElement('a');
35    const url = URL.createObjectURL(blob);
36    link.setAttribute('href', url);
37    link.setAttribute('download', filename);
38    link.style.visibility = 'hidden';
39    document.body.appendChild(link);
40    link.click();
41    document.body.removeChild(link);
42  };
43
44  return { exportToCSV, exportToJSON };
45}

Najlepsze praktyki

1. Performance optimization

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]);

2. Accessibility

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>

3. TypeScript integration

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

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 i wirtualizacja
  5. Server-side support - idealne dla dużych zbiorów danych

TanStack Table to najlepsza biblioteka do tworzenia zaawansowanych, interaktywnych tabel w React aplikacjach.

Vai a CodeWorlds