Créer un composant tableau React avec Claude Code : tri, filtre, pagination
Créez un tableau React avec Claude Code : tri, filtre, pagination, mobile, TanStack Table et Playwright.
Commencer par le contrat du tableau
Un tableau n’est pas seulement un bloc visuel. Dans une application métier, il sert à comparer des clients, suivre des factures, gérer un catalogue, prioriser des articles ou lire un rapport. La première version affiche quelques lignes, puis les demandes arrivent : trier par revenu, filtrer par statut, paginer les longues listes, garder une lecture correcte sur mobile, permettre l’usage au clavier et vérifier le tout avec un test.
Claude Code est très utile pour produire ce type de composant, mais il faut lui donner un cadre précis. Une demande vague comme “fais un joli tableau” peut générer une grille en div, oublier le caption, ajouter une flèche de tri qui ne change pas les données, ou casser la lecture mobile. Une bonne demande décrit la sémantique HTML, les états, le responsive, l’accessibilité et les commandes de vérification.
Ce guide construit un composant React/TypeScript prêt à copier. Il couvre le table sémantique, les colonnes triables, le filtre global, la pagination, l’affichage mobile, l’accessibilité, le moment où TanStack Table devient utile et les vérifications Playwright. Pour le contexte React, lisez aussi développement React avec Claude Code. Pour l’accessibilité, complétez avec accessibilité avec Claude Code.
Les références officielles à garder ouvertes sont MDN <table>, MDN aria-sort, TanStack Table, Playwright Writing tests et Claude Code overview.
Les bases d’un tableau sémantique
Utilisez un tableau quand les lignes et les colonnes créent ensemble le sens. Un client avec son plan, son MRR, son statut et sa date d’inscription est une donnée tabulaire. Si les éléments sont des cartes indépendantes, une liste ou une grille de cartes peut suffire. Si l’utilisateur compare les valeurs par colonne, le table natif reste le meilleur point de départ.
La structure minimale est caption, thead, tbody et th scope="col". Le caption décrit l’objectif du tableau. Les en-têtes de colonne sont des th. Si la première cellule identifie la ligne, utilisez th scope="row".
<table>
<caption>Revenu mensuel récurrent par client</caption>
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Plan</th>
<th scope="col">MRR</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Northwind</th>
<td>Pro</td>
<td>$1,200</td>
</tr>
</tbody>
</table>
Cette base est facile à négliger parce que le rendu peut ressembler à une grille classique. Pourtant, elle change tout pour les lecteurs d’écran, la navigation au clavier et les tests basés sur les rôles.
flowchart TD
A["Exigences"] --> B["Table sémantique"]
B --> C["Tri, filtre, pagination"]
C --> D["Affichage mobile"]
D --> E["Accessibilité"]
E --> F["Tests Playwright"]
Prompt pour Claude Code
Le prompt doit limiter le périmètre, nommer les données et définir la validation. Voici un modèle utilisable dans un vrai projet.
Crée un tableau de clients en React + TypeScript.
Contraintes:
- Modifier seulement src/components/DataTable.tsx et src/components/data-table.css
- Utiliser table, caption, thead, tbody et th scope
- Champs client: id, name, plan, mrr, status, signedUpAt
- Ajouter filtre global, tri par colonne et pagination de 5 lignes
- Ajouter aria-sort uniquement sur la colonne actuellement triée
- Utiliser un button dans chaque en-tête triable
- En mobile, afficher les libellés via data-label
- Ajouter un test Playwright pour filtre, tri, pagination et mobile
Interdictions:
- Ajouter une nouvelle bibliothèque UI
- Ajouter role="grid" sans le modèle clavier correspondant
- Répondre avec du pseudocode
Ce format force Claude Code à livrer du code exécutable, pas seulement une maquette. Il protège aussi le projet contre les dépendances inutiles.
Implémentation React/TypeScript copiable
Cette version ne dépend pas d’une bibliothèque de tableau. Elle convient aux listes de petite ou moyenne taille.
// src/components/DataTable.tsx
"use client";
import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";
type SortDirection = "asc" | "desc";
type SortState<T> = { key: keyof T; direction: SortDirection } | null;
type Customer = {
id: string;
name: string;
plan: "Free" | "Pro" | "Enterprise";
mrr: number;
status: "active" | "trial" | "paused";
signedUpAt: string;
};
type Column<T> = {
key: keyof T;
label: string;
numeric?: boolean;
render?: (value: T[keyof T], row: T) => ReactNode;
};
const pageSize = 5;
const rows: Customer[] = [
{ id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
{ id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
{ id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
{ id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
{ id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
{ id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const columns: Column<Customer>[] = [
{ key: "name", label: "Customer" },
{ key: "plan", label: "Plan" },
{ key: "mrr", label: "MRR", numeric: true, render: (_, row) => money.format(row.mrr) },
{ key: "status", label: "Status" },
{ key: "signedUpAt", label: "Signed up", render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US") },
];
function compare<T>(a: T, b: T, key: keyof T) {
const left = a[key];
const right = b[key];
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" });
}
export function DataTable() {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sort, setSort] = useState<SortState<Customer>>({ key: "name", direction: "asc" });
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
columns.some((column) => String(row[column.key]).toLowerCase().includes(keyword)),
);
}, [query]);
const sorted = useMemo(() => {
if (!sort) return filtered;
return [...filtered].sort((a, b) => {
const result = compare(a, b, sort.key);
return sort.direction === "asc" ? result : -result;
});
}, [filtered, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
function updateQuery(value: string) {
setQuery(value);
setPage(1);
}
function toggleSort(key: keyof Customer) {
setSort((current) => {
if (!current || current.key !== key) return { key, direction: "asc" };
return { key, direction: current.direction === "asc" ? "desc" : "asc" };
});
}
return (
<section className="table-shell" aria-labelledby="customers-title">
<label>
<span>Filter customers</span>
<input value={query} onChange={(event) => updateQuery(event.target.value)} type="search" />
</label>
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<caption id="customers-title">Monthly recurring revenue by customer</caption>
<thead>
<tr>
{columns.map((column) => {
const isSorted = sort?.key === column.key;
const ariaSort = isSorted ? (sort.direction === "asc" ? "ascending" : "descending") : undefined;
return (
<th key={String(column.key)} scope="col" aria-sort={ariaSort} className={column.numeric ? "numeric" : undefined}>
<button type="button" onClick={() => toggleSort(column.key)}>{column.label}</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => {
const content = column.render ? column.render(row[column.key], row) : String(row[column.key]);
return index === 0 ? (
<th key={String(column.key)} scope="row" data-label={column.label}>{content}</th>
) : (
<td key={String(column.key)} data-label={column.label} className={column.numeric ? "numeric" : undefined}>{content}</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Table pagination">
<button type="button" disabled={currentPage === 1} onClick={() => setPage((value) => value - 1)}>Previous</button>
<span aria-live="polite">Page {currentPage} of {totalPages}</span>
<button type="button" disabled={currentPage === totalPages} onClick={() => setPage((value) => value + 1)}>Next</button>
</nav>
</section>
);
}
La ligne la plus facile à oublier est setPage(1) dans updateQuery. Sans elle, une recherche effectuée depuis la page 2 peut afficher une page vide alors que des résultats existent.
CSS mobile et accessibilité
Le CSS suivant garde le tableau dans le DOM et transforme seulement sa présentation en dessous de 640px.
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
border-top: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.data-table .numeric {
text-align: right;
}
.pagination {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
@media (max-width: 640px) {
.data-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.data-table,
.data-table tbody,
.data-table tr,
.data-table th,
.data-table td {
display: block;
width: 100%;
}
.data-table tr {
border: 1px solid #d8dee8;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
}
.data-table th,
.data-table td {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
}
.data-table th::before,
.data-table td::before {
content: attr(data-label);
font-weight: 700;
}
}
Vérifiez ensuite caption, scope, le bouton de tri, aria-sort, le label du filtre et aria-live pour la pagination. Évitez role="grid" si vous ne construisez pas une vraie grille interactive avec son modèle clavier.
Quand choisir TanStack Table
Un composant maison suffit pour une liste courte. TanStack Table devient intéressant avec visibilité des colonnes, filtres par colonne, sélection de lignes, pagination serveur, colonnes fixes ou virtualisation. C’est une bibliothèque headless : elle gère la logique, mais vous gardez le contrôle du HTML et du style.
| Option | Cas adapté | Point de vigilance |
|---|---|---|
| Composant maison | Peu de colonnes, filtre et tri simples | Chaque nouvelle option est à maintenir |
| TanStack Table | État complexe, serveur, sélection | API à apprendre et UI à construire |
| Grid entreprise | Édition type tableur, gros volumes | Poids, configuration, licence |
Demandez à Claude Code de justifier l’ajout de dépendance. Une table de paramètres n’a pas besoin de la même architecture qu’un CRM.
Tests Playwright
Un bon test vérifie le parcours utilisateur : voir le tableau, trier, filtrer, paginer et lire le mobile.
// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";
test("customer table works", async ({ page }) => {
await page.goto("/customers");
await expect(page.getByRole("table", { name: /monthly recurring revenue/i })).toBeVisible();
await page.getByRole("button", { name: /MRR/ }).click();
await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute("aria-sort", "ascending");
await page.getByLabel("Filter customers").fill("north");
await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();
await page.getByLabel("Filter customers").fill("");
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Page 2 of 2")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});
Donnez ce test à Claude Code lors d’une correction et demandez-lui de reproduire l’échec avant de modifier le composant.
Cas d’usage, pièges et CTA
| Cas d’usage | Fonctions utiles | Valeur |
|---|---|---|
| Liste clients SaaS | Plan, MRR, statut, renouvellement | Repérer churn et upsell |
| Catalogue e-commerce | Stock, prix, catégorie, publication | Détecter erreurs et ruptures |
| Tableau éditorial | PV, lecture, clics CTA, mise à jour | Prioriser SEO et revenus |
| Facturation | État, montant, échéance | Réduire le support manuel |
Les pièges fréquents sont les faux tableaux en div, les flèches de tri sans état réel, l’oubli de revenir en page 1 après filtrage, le mobile traité trop tard et l’ajout d’une bibliothèque UI inutile. Inscrivez ces points dans le prompt.
Un tableau aide aussi la monétisation quand il rend l’action suivante visible. Un média peut rapprocher trafic, clics CTA et revenus par article. Un SaaS peut rapprocher MRR, baisse d’usage et date de renouvellement. Pour mettre ce flux en place en équipe, consultez formation et accompagnement Claude Code. Pour apprendre seul, démarrez avec produits et modèles.
Résultat testé
Masa a testé cette structure sur une petite liste de clients. Le correctif le plus utile a été le retour à la page 1 après filtrage : sans cela, une recherche pouvait sembler vide. Le second gain a été d’ajouter data-label dès le début, plutôt que de bricoler le CSS mobile à la fin. Demander à Claude Code la sémantique, l’état, le responsive, l’accessibilité et Playwright dans une même tâche donne un composant plus facile à relire.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Échelle de sécurité des permissions Claude Code
Passer du read-only aux éditions limitées, preuves et checks de déploiement sans perdre le contrôle.
Claude Code Small PR Proof Pack : rendre les petits changements reviewables
Un pack de preuve pour PR Claude Code : diff, vérifications, URL publique, CTA et rollback.
Gate de review avant commit avec Claude Code
Review avant commit avec Claude Code : diff, build, URL publique, liens Gumroad, CTA consultation, tests manquants et fichiers hors scope.