Cours Next.js · Module 9: Intégrations et fonctions avancées
Next.js avec TanStack Table - Tableaux de données avancés
Dans cette leçon11
Le panneau des gardiens de la Métropole Quantum affiche des milliers d'enregistrements : utilisateurs, rôles, statuts. Un simple élément table avec une boucle map suffit une semaine, puis quelqu'un demande le tri, une barre de recherche, la pagination et la sélection de plusieurs lignes à la fois. Écrire tout cela à la main, ce sont des centaines de lignes de logique fragile. Matrix, l'architecte des données de la Métropole, règle le problème avec TanStack Table - une bibliothèque qui calcule la logique du tableau et te laisse l'apparence.
Qu'est-ce que TanStack Table ?
TanStack Table (anciennement React Table) est une bibliothèque headless pour construire des tableaux, qui offre :
- Headless UI - un contrôle total sur l'apparence
- TypeScript-first - un support complet du typage
- Framework agnostic - fonctionne avec React, Vue, Svelte
- Feature-rich - tri, filtrage, pagination, grouping
- Performance - des optimisations pour les grands jeux de données
- Extensible - un riche système de plugins
"Headless" signifie que la bibliothèque ne rend aucun HTML. Tu obtiens une API avec un état et des méthodes, et tu écris toi-même le balisage et les styles.
Avantages de TanStack Table
- Aucune dépendance UI - pas de composants ni de styles prêts à l'emploi
- Tree-shakable - seulement les fonctions nécessaires dans le bundle
- Server-side support - idéal pour le SSR/SSG
- Accessibility - l'accessibilité dépend de ton HTML, puisque c'est toi qui l'écris
- Mobile-friendly - des tableaux responsives
Ce que TanStack Table ne fait PAS : il ne récupère pas les données et n'a pas de virtualisation intégrée. Tu fournis les données toi-même (par exemple avec TanStack Query), et la virtualisation des longues listes vient de la bibliothèque séparée TanStack Virtual.
Installation et configuration de base
Installation
Dans un projet React, tu installes l'adaptateur @tanstack/react-table, qui contient déjà le cœur de la bibliothèque :
1# Installe TanStack Table
2npm install @tanstack/react-table
3
4# Cœur indépendant du framework (react-table l'installe automatiquement)
5npm install @tanstack/table-coreLe paquet @tanstack/table-core est le cœur indépendant du framework. L'adaptateur React l'installe automatiquement, donc une installation séparée est optionnelle.
Tableau de base
Chaque tableau commence par un type de données. L'interface User décrit une ligne, et les unions de littéraux, comme 'admin' | 'user' | 'moderator', limitent les valeurs autorisées :
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}Grâce à cela, TypeScript suggérera les noms de colonnes et détectera les fautes de frappe dans les clés.
On définit maintenant les colonnes. La fonction createColumnHelper<User>() crée un assistant dont la méthode accessor désigne un champ de l'objet, tandis que cell décide comment afficher la valeur :
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: "Nom d'utilisateur",
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: 'Rôle',
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: 'Date de création',
63 cell: (info) => new Date(info.getValue()).toLocaleDateString('fr-FR'),
64 }),
65];
66Les colonnes ne sont qu'un tableau de configuration, sans aucun tableau affiché pour l'instant. Le rôle et le statut reçoivent des badges colorés, et la date est formatée selon la locale française.
Le composant relie les données et les colonnes avec le hook useReactTable. La configuration minimale comprend data, columns et getCoreRowModel, le modèle de lignes de base :
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 affiche un en-tête ou une cellule, que la définition contienne du texte ou une fonction. Tout le HTML et les classes Tailwind sont à nous : la bibliothèque n'a fourni que les groupes d'en-têtes et les lignes.
Tri et filtrage
Tri
Chaque fonctionnalité du tableau suit le même schéma : un état dans useState, son branchement via state et on...Change, et l'ajout du modèle de lignes correspondant. Pour le tri, ce sont SortingState et 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: "Nom d'utilisateur",
21 enableSorting: true,
22 }),
23 columnHelper.accessor('email', {
24 header: 'Email',
25 enableSorting: true,
26 }),
27 columnHelper.accessor('role', {
28 header: 'Rôle',
29 enableSorting: true,
30 }),
31 columnHelper.accessor('status', {
32 header: 'Status',
33 enableSorting: true,
34 }),
35 columnHelper.accessor('createdAt', {
36 header: 'Date de création',
37 enableSorting: true,
38 sortingFn: 'datetime',
39 cell: (info) => new Date(info.getValue()).toLocaleDateString('fr-FR'),
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}Un clic sur un en-tête appelle getToggleSortingHandler(), qui fait défiler l'ordre : croissant, décroissant, sans tri. sortingFn: 'datetime' trie les dates chronologiquement et non alphabétiquement.
Filtrage
Le filtrage ajoute deux types d'état : les filtres de colonnes (ColumnFiltersState) et un filtre global pour la barre de recherche. D'abord la configuration avec une fonction globalFilterFn personnalisée :
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// Fonction de filtre global
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: "Nom d'utilisateur",
34 enableColumnFilter: true,
35 filterFn: 'includesString',
36 }),
37 columnHelper.accessor('email', {
38 header: 'Email',
39 enableColumnFilter: true,
40 }),
41 columnHelper.accessor('role', {
42 header: 'Rôle',
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: 'Date de création',
53 enableColumnFilter: false,
54 cell: (info) => new Date(info.getValue()).toLocaleDateString('fr-FR'),
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 });
83Tu choisis les fonctions de filtre intégrées, comme includesString ou equals, par leur nom. Le modèle getFilteredRowModel écarte les lignes qui ne correspondent pas aux filtres, et le tri continue de s'appliquer à ce qui reste.
L'interface des filtres se compose de simples champs de formulaire qui définissent les valeurs via l'API du tableau :
1 return (
2 <div className="space-y-4">
3 {/* Global Search */}
4 <div className="flex items-center space-x-4">
5 <div className="relative flex-1 max-w-sm">
6 <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
7 <input
8 value={globalFilter ?? ''}
9 onChange={(e) => setGlobalFilter(e.target.value)}
10 className="pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 w-full"
11 />
12 </div>
13
14 {/* Roles Filter */}
15 <select
16 value={(table.getColumn('role')?.getFilterValue() as string) ?? ''}
17 onChange={(e) =>
18 table.getColumn('role')?.setFilterValue(e.target.value || undefined)
19 }
20 className="px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
21 >
22 <option value="">Tous les rôles</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="">Tous les statuts</option>
37 <option value="active">Actif</option>
38 <option value="inactive">Inactif</option>
39 <option value="pending">En attente</option>
40 </select>
41 </div>
42
43 {/* Results Info */}
44 <div className="text-sm text-gray-600">
45 Affichage de {table.getFilteredRowModel().rows.length} sur {data.length} utilisateurs
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}Dans tbody, on utilise getRowModel(), car il renvoie le résultat final après filtrage et tri. getFilteredRowModel() ignorerait le tri, donc ici il ne sert qu'au compteur de résultats.
Pagination
La pagination garde dans l'état le numéro de page (pageIndex, compté à partir de zéro) et la taille de page (pageSize). Le modèle getPaginationRowModel découpe les lignes en pages :
1// components/PaginatedUsersTable.tsx
2'use client';
3
4import {
5 createColumnHelper,
6 flexRender,
7 getCoreRowModel,
8 getPaginationRowModel,
9 getSortedRowModel,
10 getFilteredRowModel,
11 PaginationState,
12 useReactTable,
13} from '@tanstack/react-table';
14import { useState } from 'react';
15import {
16 ChevronLeftIcon,
17 ChevronRightIcon,
18 ChevronDoubleLeftIcon,
19 ChevronDoubleRightIcon
20} from '@heroicons/react/24/outline';
21import { User } from '@/types/User';
22
23const columnHelper = createColumnHelper<User>();
24
25const columns = [
26 columnHelper.accessor('name', {
27 header: "Nom d'utilisateur",
28 }),
29 columnHelper.accessor('email', {
30 header: 'Email',
31 }),
32 columnHelper.accessor('role', {
33 header: 'Rôle',
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: 'Date de création',
62 cell: (info) => new Date(info.getValue()).toLocaleDateString('fr-FR'),
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 });
88Le tableau calcule lui-même le nombre de pages à partir du nombre de lignes et de pageSize. Tu n'as pas besoin de garder une tranche des données dans une variable séparée.
Les contrôles de navigation utilisent des méthodes prêtes à l'emploi comme previousPage(), nextPage() et setPageIndex() :
1 return (
2 <div className="space-y-4">
3 {/* Table */}
4 <div className="overflow-x-auto">
5 <table className="min-w-full bg-white border border-gray-200">
6 <thead className="bg-gray-50">
7 {table.getHeaderGroups().map((headerGroup) => (
8 <tr key={headerGroup.id}>
9 {headerGroup.headers.map((header) => (
10 <th
11 key={header.id}
12 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
13 >
14 {flexRender(header.column.columnDef.header, header.getContext())}
15 </th>
16 ))}
17 </tr>
18 ))}
19 </thead>
20 <tbody className="bg-white divide-y divide-gray-200">
21 {table.getRowModel().rows.map((row) => (
22 <tr key={row.id} className="hover:bg-gray-50">
23 {row.getVisibleCells().map((cell) => (
24 <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
25 {flexRender(cell.column.columnDef.cell, cell.getContext())}
26 </td>
27 ))}
28 </tr>
29 ))}
30 </tbody>
31 </table>
32 </div>
33
34 {/* Pagination */}
35 <div className="flex items-center justify-between">
36 <div className="flex items-center space-x-2">
37 <span className="text-sm text-gray-700">
38 Showing{' '}
39 <span className="font-medium">
40 {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
41 </span>{' '}
42 -{' '}
43 <span className="font-medium">
44 {Math.min(
45 (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
46 table.getFilteredRowModel().rows.length
47 )}
48 </span>{' '}
49 of{' '}
50 <span className="font-medium">{table.getFilteredRowModel().rows.length}</span> results
51 </span>
52
53 <select
54 value={table.getState().pagination.pageSize}
55 onChange={(e) => {
56 table.setPageSize(Number(e.target.value));
57 }}
58 className="px-3 py-1 border border-gray-300 rounded text-sm"
59 >
60 {[10, 20, 30, 40, 50].map((pageSize) => (
61 <option key={pageSize} value={pageSize}>
62 {pageSize} par page
63 </option>
64 ))}
65 </select>
66 </div>
67
68 <div className="flex items-center space-x-2">
69 <button
70 onClick={() => table.setPageIndex(0)}
71 disabled={!table.getCanPreviousPage()}
72 className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
73 >
74 <ChevronDoubleLeftIcon className="w-4 h-4" />
75 </button>
76
77 <button
78 onClick={() => table.previousPage()}
79 disabled={!table.getCanPreviousPage()}
80 className="p-2 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
81 >
82 <ChevronLeftIcon className="w-4 h-4" />
83 </button>
84
85 <div className="flex items-center space-x-1">
86 <span className="text-sm text-gray-700">Page</span>
87 <input
88 type="number"
89 value={table.getState().pagination.pageIndex + 1}
90 onChange={(e) => {
91 const page = e.target.value ? Number(e.target.value) - 1 : 0;
92 table.setPageIndex(page);
93 }}
94 className="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
95 min={1}
96 max={table.getPageCount()}
97 />
98 <span className="text-sm text-gray-700">sur {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() et getCanNextPage() désactivent les boutons aux extrémités. Le champ du numéro de page soustrait 1, car l'utilisateur compte à partir de un et le tableau à partir de zéro.
Sélection de lignes
La sélection ajoute une colonne de cases à cocher. La méthode columnHelper.display crée une colonne sans champ de données, parfaite pour les cases à cocher et les boutons d'action :
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 // Colonne de sélection
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: "Nom d'utilisateur",
53 }),
54 columnHelper.accessor('email', {
55 header: 'Email',
56 }),
57 columnHelper.accessor('role', {
58 header: 'Rôle',
59 }),
60 columnHelper.accessor('status', {
61 header: 'Status',
62 }),
63 // Colonne d'actions
64 columnHelper.display({
65 id: 'actions',
66 header: 'Actions',
67 cell: ({ row }) => (
68 <div className="flex space-x-2">
69 <button
70 onClick={() => console.log('Edit user:', row.original)}
71 className="text-blue-600 hover:text-blue-800 text-sm"
72 >
73 Modifier
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 Supprimer
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);
98L'état RowSelectionState est un objet dont les clés sont les id des lignes. Les colonnes sont ici définies dans le composant, il vaut donc la peine de les envelopper dans useMemo, comme l'expliquent les bonnes pratiques à la fin de la leçon.
La barre d'actions groupées n'apparaît que lorsque quelque chose est sélectionné :
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 {selectedUsers.length} utilisateurs sélectionnés
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 Activer
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 Désactiver
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>Supprimer</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() renvoie les lignes sélectionnées, et row.original est l'objet User d'origine. Le tableau ne supprime pas les données lui-même, il les transmet aux fonctions reçues en props.
Récupération des données côté serveur
Avec des dizaines de milliers d'enregistrements, on déplace le tri et le filtrage sur le serveur. L'ordre est fixe : un endpoint d'API, un état manuel de tri et de filtres dans le tableau, le traitement sur le serveur et la gestion de la réponse dans le composant. TanStack Query récupère les données :
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 contient les paramètres, donc chaque changement de page ou de tri récupère de nouvelles données. placeholderData: keepPreviousData est l'écriture de TanStack Query 5 qui affiche la page précédente jusqu'à l'arrivée de la nouvelle, l'ancienne option keepPreviousData: true ne fonctionne plus.
Le composant transmet l'état du tableau au hook et active le mode manuel avec les indicateurs manualPagination, manualSorting et 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: "Nom d'utilisateur",
22 enableSorting: true,
23 }),
24 columnHelper.accessor('email', {
25 header: 'Email',
26 enableSorting: true,
27 }),
28 columnHelper.accessor('role', {
29 header: 'Rôle',
30 enableSorting: true,
31 }),
32 columnHelper.accessor('status', {
33 header: 'Status',
34 enableSorting: true,
35 }),
36 columnHelper.accessor('createdAt', {
37 header: 'Date de création',
38 enableSorting: true,
39 cell: (info) => new Date(info.getValue()).toLocaleDateString('fr-FR'),
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 Une erreur est survenue lors du chargement des données
92 </div>
93 );
94 }
95En mode manuel, le tableau ne trie ni ne filtre lui-même, il signale seulement les changements d'état. pageCount vient du serveur, car seul le serveur connaît le nombre total d'enregistrements.
La vue ressemble presque à la version client :
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 Affichage de {data?.data.length ?? 0} sur {data?.totalCount ?? 0} utilisateurs
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 Premier
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 Précédent
73 </button>
74
75 <span className="text-sm">
76 Page {table.getState().pagination.pageIndex + 1} sur{' '}
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 Suivant
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 Dernier
94 </button>
95 </div>
96 </div>
97 </div>
98 );
99}La différence est invisible pour l'utilisateur : chaque clic sur une page envoie une nouvelle requête à l'API.
API Routes pour un tableau côté serveur
Côté serveur, un Route Handler lit les paramètres dans searchParams et fait le même travail que la bibliothèque auparavant :
1// app/api/users/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { User } from '@/types/User';
4
5// Données simulées
6const mockUsers: User[] = [
7 // ... données utilisateur
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 // Recherche globale
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 // Filtres de colonne
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 // Tri
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}Dans l'ordre : recherche, filtres de colonnes, tri et découpage de la page. Dans une vraie application, la base de données ferait ces étapes avec une requête utilisant WHERE, ORDER BY et LIMIT.
Tableaux responsives
Un tableau large ne tient pas sur un téléphone. Ce composant affiche le tableau à partir du point de rupture md, et sur les petits écrans des cartes avec des détails dépliables :
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 {/* Tableau desktop standard */}
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) ? 'Moins' : 'Plus'}
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">Rôle :</span>
57 <span className="ml-1">{user.role}</span>
58 </div>
59 <div>
60 <span className="font-medium text-gray-700">Statut :</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">Créé le :</span>
65 <span className="ml-1">
66 {new Date(user.createdAt).toLocaleDateString('fr-FR')}
67 </span>
68 </div>
69 </div>
70 </div>
71 )}
72 </div>
73 </div>
74 ))}
75 </div>
76 </div>
77 );
78}L'état de dépliage est un Set d'identifiants. On crée un nouveau Set à chaque changement au lieu de muter l'ancien, pour que React remarque le changement.
Export des données
L'export en CSV et JSON ne nécessite aucune bibliothèque. Le hook crée le fichier sous forme de Blob et le télécharge via un lien temporaire :
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', 'Nom', 'Email', 'Rôle', 'Statut', 'Date de création'];
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('fr-FR')
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}Les lignes CSV sont reliées par un caractère de nouvelle ligne. Cet export simple ne gère pas les virgules à l'intérieur des valeurs, en production entoure ces champs de guillemets.
Bonnes pratiques
1. Optimisation des performances
Le tableau compare data et columns par référence, donc de nouveaux tableaux à chaque rendu forcent un nouveau calcul :
1// Bien - utilise memo pour les colonnes
2const columns = useMemo(() => [
3 // définitions des colonnes
4], []);
5
6// Bien - memo pour les données
7const data = useMemo(() => users, [users]);useMemo conserve la même référence entre les rendus tant que les dépendances ne changent pas.
2. Accessibilité
Headless signifie que l'accessibilité dépend de toi. Utilise de vraies balises de tableau et des attributs ARIA :
1// Bien - aria labels
2<table role="table" aria-label="Users table">
3 <thead>
4 <tr role="row">
5 <th role="columnheader" aria-sort="ascending">
6 Name
7 </th>
8 </tr>
9 </thead>
10</table>aria-sort indique aux lecteurs d'écran selon quelle colonne et dans quel sens les données sont triées.
3. Intégration TypeScript
Un assistant de colonnes typé avec tes données te donne des suggestions et vérifie les noms des champs :
1// Bien - typage fort
2const columnHelper = createColumnHelper<User>();
3
4const columns = [
5 columnHelper.accessor('name', {
6 // Support IntelliSense complet
7 }),
8];Une faute de frappe dans un nom de champ devient une erreur de compilation, et non une colonne vide en production.
Résumé
TanStack Table avec Next.js forme une combinaison puissante pour construire des tableaux avancés :
- Headless UI - contrôle total sur le style
- TypeScript-first - sécurité des types
- Feature-rich - tri, filtrage, pagination
- Performance - des optimisations, avec la virtualisation de TanStack Virtual
- Server-side support - idéal pour les grands jeux de données
Mon conseil : garde la logique côté client jusqu'à quelques milliers de lignes, et au-delà passe au mode manuel avec une API. Dans la leçon suivante, tu découvriras les Typed Routes, qui protègent tes liens des fautes de frappe.
Retiens : TanStack Table est le moteur de calcul du tableau, et l'apparence de chaque cellule t'appartient.
Code de cette leçon : App.tsx
1import React, { useState, useMemo } from 'react';
2
3interface User {
4 id: number;
5 name: string;
6 email: string;
7 role: 'admin' | 'user' | 'moderator';
8 status: 'active' | 'inactive' | 'pending';
9 lastLogin: string;
10}
11
12const mockUsers: User[] = [
13 { id: 1, name: 'Jan Kowalski', email: 'jan@quantum.city', role: 'admin', status: 'active', lastLogin: '2025-03-01' },
14 { id: 2, name: 'Anna Nowak', email: 'anna@quantum.city', role: 'moderator', status: 'active', lastLogin: '2025-02-28' },
15 { id: 3, name: 'Piotr Wisniewski', email: 'piotr@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-25' },
16 { id: 4, name: 'Maria Zielinska', email: 'maria@quantum.city', role: 'user', status: 'active', lastLogin: '2025-03-02' },
17 { id: 5, name: 'Tomasz Dabrowski', email: 'tomasz@quantum.city', role: 'moderator', status: 'inactive', lastLogin: '2025-01-15' },
18 { id: 6, name: 'Katarzyna Lewandowska', email: 'kasia@quantum.city', role: 'user', status: 'active', lastLogin: '2025-03-03' },
19 { id: 7, name: 'Michal Wojcik', email: 'michal@quantum.city', role: 'admin', status: 'active', lastLogin: '2025-03-02' },
20 { id: 8, name: 'Agnieszka Kaminska', email: 'aga@quantum.city', role: 'user', status: 'pending', lastLogin: '2025-02-20' },
21 { id: 9, name: 'Robert Szymanski', email: 'robert@quantum.city', role: 'user', status: 'inactive', lastLogin: '2024-12-01' },
22 { id: 10, name: 'Ewa Wozniak', email: 'ewa@quantum.city', role: 'moderator', status: 'active', lastLogin: '2025-03-01' },
23];
24
25type SortDir = 'asc' | 'desc' | null;
26
27const statusColors: Record<string, string> = { active: '#4caf50', inactive: '#f44336', pending: '#ff9800' };
28const roleColors: Record<string, string> = { admin: '#7c4dff', moderator: '#64ffda', user: '#888' };
29
30const TanStackTableDemo = () => {
31 const [data] = useState<User[]>(mockUsers);
32 const [sortKey, setSortKey] = useState<keyof User | null>(null);
33 const [sortDir, setSortDir] = useState<SortDir>(null);
34 const [searchQuery, setSearchQuery] = useState('');
35 const [roleFilter, setRoleFilter] = useState<string>('all');
36 const [statusFilter, setStatusFilter] = useState<string>('all');
37 const [page, setPage] = useState(0);
38 const pageSize = 5;
39
40 const handleSort = (key: keyof User) => {
41 if (sortKey === key) {
42 setSortDir(prev => prev === 'asc' ? 'desc' : prev === 'desc' ? null : 'asc');
43 if (sortDir === 'desc') setSortKey(null);
44 } else {
45 setSortKey(key);
46 setSortDir('asc');
47 }
48 };
49
50 const filtered = useMemo(() => {
51 let result = [...data];
52 if (searchQuery) result = result.filter(u => u.name.toLowerCase().includes(searchQuery.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.toLowerCase()));
53 if (roleFilter !== 'all') result = result.filter(u => u.role === roleFilter);
54 if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
55 if (sortKey && sortDir) {
56 result.sort((a, b) => {
57 const va = a[sortKey], vb = b[sortKey];
58 const cmp = String(va).localeCompare(String(vb));
59 return sortDir === 'asc' ? cmp : -cmp;
60 });
61 }
62 return result;
63 }, [data, searchQuery, roleFilter, statusFilter, sortKey, sortDir]);
64
65 const paged = filtered.slice(page * pageSize, (page + 1) * pageSize);
66 const totalPages = Math.ceil(filtered.length / pageSize);
67
68 const sortIcon = (key: keyof User) => {
69 if (sortKey !== key) return ' ↕';
70 return sortDir === 'asc' ? ' ↑' : ' ↓';
71 };
72
73 return (
74 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
75 <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Next.js + TanStack Table</h1>
76 <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>Tri, filtrage, 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="Rechercher par nom ou 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">Tous les rôles</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">Tous les statuts</option>
92 <option value="active">Actif</option>
93 <option value="inactive">Inactif</option>
94 <option value="pending">En attente</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: 'Nom' },
104 { key: 'email' as keyof User, label: 'Email' },
105 { key: 'role' as keyof User, label: 'Rôle' },
106 { key: 'status' as keyof User, label: 'Statut' },
107 { key: 'lastLogin' as keyof User, label: 'Dernière connexion' },
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' }}>Aucun résultat trouvé</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' }}>Affichage de {paged.length} sur {filtered.length} utilisateurs</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' }}>Préc</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' }}>Suiv</button>
145 </div>
146 </div>
147 </div>
148 </div>
149 );
150};
151
152export default TanStackTableDemo;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. TanStack Table propose :
2. Quelle fonctionnalité n'est PAS intégrée à TanStack Table ?
Tâches pratiques dans le jeu
- Éditeur de code
Implémente un tableau de données avec tri et filtrage à l'aide de TanStack Table
- Éditeur de code
Crée un tableau avec des colonnes et une source de données.