TanStack Table est la référence pour les tableaux de données headless dans React. Il vous donne un contrôle total sur le balisage et le style tout en gérant toute la logique complexe — tri, filtrage, pagination, virtualisation — dès le départ. Ce tutoriel construit un tableau de bord de facturation complet que vous pouvez adapter à tout projet réel.
Ce que vous allez construire
Une application Tableau de bord des factures comprenant :
- Colonnes triables (ordre croissant et décroissant)
- Filtres par colonne et recherche globale
- Pagination compatible serveur
- Sélection multiple avec actions groupées
- Basculement de visibilité des colonnes
- Export CSV
- Typage TypeScript complet
- Stylisé avec Tailwind CSS
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Bonne compréhension de React et TypeScript
- Familiarité avec le Next.js App Router
- Connaissances de base en Tailwind CSS
- Un éditeur de code (VS Code recommandé)
Pourquoi TanStack Table ?
La plupart des bibliothèques de tableaux livrent des composants rigides qui vous forcent dans leur système visuel. TanStack Table adopte l'approche inverse — c'est un utilitaire headless qui gère l'état et la logique sans rien rendre. Vous écrivez vous-même le <table>, le <thead> et le <tbody>, ce qui signifie :
- Zéro conflit de style avec votre système de design
- Contrôle total sur l'accessibilité
- Petite taille de bundle (moins de 15 Ko compressé)
- Compatible avec toute bibliothèque d'interface : Tailwind, shadcn/ui, Radix, Material UI
Étape 1 : Configuration du projet
Créez un nouveau projet Next.js 15 :
npx create-next-app@latest invoice-dashboard --typescript --tailwind --eslint --app
cd invoice-dashboardInstallez TanStack Table :
npm install @tanstack/react-tableStructure de fichiers initiale :
invoice-dashboard/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── data-table/
├── lib/
│ └── data.ts
└── types/
└── invoice.ts
Étape 2 : Définir les types et les données fictives
Créez le type de facture :
// types/invoice.ts
export type InvoiceStatus = "paid" | "pending" | "overdue";
export interface Invoice {
id: string;
invoiceNumber: string;
client: string;
email: string;
amount: number;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
}Créez des données fictives pour remplir le tableau :
// lib/data.ts
import { Invoice } from "@/types/invoice";
export const invoices: Invoice[] = [
{
id: "1",
invoiceNumber: "FAC-001",
client: "Acme Corp",
email: "facturation@acme.fr",
amount: 4200,
status: "paid",
issueDate: "2026-01-10",
dueDate: "2026-02-10",
},
{
id: "2",
invoiceNumber: "FAC-002",
client: "Globex SAS",
email: "comptabilite@globex.fr",
amount: 8750,
status: "pending",
issueDate: "2026-01-15",
dueDate: "2026-02-15",
},
{
id: "3",
invoiceNumber: "FAC-003",
client: "Initech SARL",
email: "finance@initech.io",
amount: 1350,
status: "overdue",
issueDate: "2025-12-01",
dueDate: "2026-01-01",
},
{
id: "4",
invoiceNumber: "FAC-004",
client: "Umbrella SA",
email: "paiement@umbrella.fr",
amount: 12000,
status: "paid",
issueDate: "2026-02-01",
dueDate: "2026-03-01",
},
];Étape 3 : Définir les configurations de colonnes
Les définitions de colonnes sont le cœur de TanStack Table. Chaque colonne décrit comment accéder aux données, comment les afficher et quelles fonctionnalités s'appliquent :
// components/data-table/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Invoice, InvoiceStatus } from "@/types/invoice";
import { ArrowUpDown } from "lucide-react";
const statusStyles: Record<InvoiceStatus, string> = {
paid: "bg-green-100 text-green-800",
pending: "bg-yellow-100 text-yellow-800",
overdue: "bg-red-100 text-red-800",
};
const statusLabels: Record<InvoiceStatus, string> = {
paid: "Payée",
pending: "En attente",
overdue: "En retard",
};
export const columns: ColumnDef<Invoice>[] = [
{
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "invoiceNumber",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Facture
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "client",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Client
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "email",
header: "E-mail",
enableHiding: true,
},
{
accessorKey: "amount",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Montant
<ArrowUpDown className="h-4 w-4" />
</button>
),
cell: ({ row }) =>
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(row.getValue("amount")),
},
{
accessorKey: "status",
header: "Statut",
cell: ({ row }) => {
const status = row.getValue("status") as InvoiceStatus;
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusStyles[status]}`}
>
{statusLabels[status]}
</span>
);
},
filterFn: "equalsString",
},
{
accessorKey: "dueDate",
header: "Échéance",
cell: ({ row }) =>
new Date(row.getValue("dueDate")).toLocaleDateString("fr-FR", {
year: "numeric",
month: "short",
day: "numeric",
}),
},
];Étape 4 : Construire le composant de tableau principal
Le hook useReactTable relie toutes les fonctionnalités ensemble :
// components/data-table/DataTable.tsx
"use client";
import { useState } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
SortingState,
ColumnFiltersState,
VisibilityState,
RowSelectionState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { Invoice } from "@/types/invoice";
interface DataTableProps {
data: Invoice[];
}
export function DataTable({ data }: DataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 10 },
},
});
return (
<div className="space-y-4">
{/* Barre d'outils */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative max-w-sm w-full">
<input
type="text"
placeholder="Rechercher dans toutes les colonnes..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="w-full px-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button className="flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
Exporter CSV
</button>
</div>
{/* Tableau */}
<div className="rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={`hover:bg-gray-50 transition-colors ${
row.getIsSelected() ? "bg-indigo-50" : ""
}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-gray-500"
>
Aucune facture trouvée.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<span>
Affichage de {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)} sur {table.getFilteredRowModel().rows.length} factures
</span>
<div className="flex items-center gap-2">
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
{[5, 10, 20, 50].map((size) => (
<option key={size} value={size}>
{size} / page
</option>
))}
</select>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1 border rounded disabled:opacity-40 disabled:cursor-not-allowed"
>
Précédent
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} sur {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1 border rounded disabled:opacity-40 disabled:cursor-not-allowed"
>
Suivant
</button>
</div>
</div>
</div>
);
}Étape 5 : Intégration dans la page principale
// app/page.tsx
import { DataTable } from "@/components/data-table/DataTable";
import { invoices } from "@/lib/data";
export default function InvoiceDashboard() {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Factures</h1>
<p className="mt-1 text-sm text-gray-500">
Gérez et suivez toutes vos factures clients.
</p>
</div>
<DataTable data={invoices} />
</main>
);
}Étape 6 : Pagination côté serveur
Pour les grands ensembles de données, déplacez le tri et la pagination côté serveur. Indiquez à TanStack Table qu'il est en mode manuel :
const table = useReactTable({
data,
columns,
manualSorting: true,
manualPagination: true,
rowCount: totalCount,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
});Récupérez ensuite les données côté serveur avec les paramètres de tri et de page :
// app/api/invoices/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const page = Number(searchParams.get("page") ?? 1);
const pageSize = Number(searchParams.get("pageSize") ?? 10);
const sort = searchParams.get("sort") ?? "dueDate";
const dir = searchParams.get("dir") ?? "asc";
const data = await db.invoice.findMany({
orderBy: { [sort]: dir },
skip: (page - 1) * pageSize,
take: pageSize,
});
const total = await db.invoice.count();
return NextResponse.json({ data, total });
}Filtrage par colonne
Pour filtrer des colonnes spécifiques (par exemple, filtrer par statut), ajoutez des entrées de filtre sous les en-têtes :
// Ajoutez une ligne de filtre sous l'en-tête dans DataTable.tsx
{header.column.getCanFilter() && (
<div className="mt-1">
{header.column.id === "status" ? (
<select
value={(header.column.getFilterValue() as string) ?? ""}
onChange={(e) =>
header.column.setFilterValue(e.target.value || undefined)
}
className="w-full text-xs border border-gray-300 rounded px-1 py-0.5 font-normal"
>
<option value="">Tous</option>
<option value="paid">Payée</option>
<option value="pending">En attente</option>
<option value="overdue">En retard</option>
</select>
) : (
<input
type="text"
value={(header.column.getFilterValue() as string) ?? ""}
onChange={(e) =>
header.column.setFilterValue(e.target.value || undefined)
}
placeholder="Filtrer..."
className="w-full text-xs border border-gray-300 rounded px-2 py-0.5 font-normal"
/>
)}
</div>
)}Tester votre implémentation
Lancez le serveur de développement et vérifiez chaque fonctionnalité :
npm run devListe de vérification :
- Cliquez sur les en-têtes de colonnes — les lignes doivent se trier en ordre croissant puis décroissant
- Tapez dans la zone de recherche globale — les lignes doivent se filtrer dans toutes les colonnes
- Utilisez le sélecteur de taille de page et les boutons de navigation — la pagination doit fonctionner correctement
- Cochez des cases — les lignes sélectionnées doivent se mettre en surbrillance indigo
- Cliquez sur Exporter CSV — un fichier CSV doit se télécharger
Résolution des problèmes
Le tableau ne montre aucune ligne après le filtrage :
Assurez-vous que getFilteredRowModel est passé à useReactTable. Sans lui, les filtres n'ont aucun effet.
Le tri ne fonctionne pas :
Confirmez que getSortedRowModel est dans le hook et que l'en-tête de colonne appelle column.toggleSorting().
Erreurs TypeScript sur accessorKey :
La définition de colonne doit utiliser ColumnDef<VotreType> — assurez-vous que le générique correspond à votre type de données.
Le total de pagination est incorrect en mode manuel :
Vous devez passer rowCount à useReactTable égal au nombre total de lignes côté serveur.
Prochaines étapes
Maintenant que vous avez un tableau de données complet, envisagez ces extensions :
- Expansion de lignes — utilisez
getExpandedRowModelpour afficher des panneaux de détails sous les lignes - Réorganisation des colonnes par glisser-déposer — combinez avec
@dnd-kit/core - Défilement virtuel — utilisez
@tanstack/react-virtualpour ne rendre que les lignes visibles, supportant des millions de lignes - Persistance de l'état dans l'URL — sérialisez le tri, le filtrage et la page dans
searchParamspour des vues de tableau partageables
Pour compléter l'écosystème TanStack, associez ce tableau avec TanStack Query pour alimenter des données en direct dans vos colonnes, et TanStack Form pour l'édition en ligne.
Conclusion
TanStack Table offre une expérience de tableau de données de niveau production sans vous enfermer dans une bibliothèque de composants rigide. En possédant la couche de rendu, vous pouvez correspondre à n'importe quel système de design, implémenter des rendus de cellules personnalisés et passer à des sources de données côté serveur — tout avec l'inférence TypeScript complète guidant chaque définition de colonne.