React-Tabellenkomponente mit Claude Code bauen: Sortieren, Filtern, Pagination
Baue mit Claude Code eine React-Tabelle mit Sortierung, Filter, Pagination, Mobile-CSS, TanStack Table und Playwright.
Erst den Tabellenvertrag festlegen
Tabellen wirken einfach, bis sie in einem echten Produkt genutzt werden. In Admin-Oberflächen, CRM-Listen, Rechnungen, Produktkatalogen und Content-Dashboards braucht man schnell Sortierung, Filter, Pagination, mobile Darstellung, Tastaturbedienung und Tests. Eine Tabelle ist keine Dekoration, sondern eine Oberfläche, auf der Entscheidungen getroffen werden.
Claude Code kann diese Arbeit stark beschleunigen. Dafür muss der Auftrag aber präzise sein. “Mach eine schöne Tabelle” führt leicht zu einem div-Raster ohne semantischen Wert, einem Sortierpfeil ohne echten Sortierzustand oder einer mobilen Ansicht, die Spaltennamen verliert. Eine gute Anweisung beschreibt HTML-Semantik, Zustand, responsives Verhalten, Barrierefreiheit und Prüfungen.
In diesem Artikel bauen wir eine kopierbare React/TypeScript-Tabelle. Sie enthält semantisches table, sortierbare Spalten, globalen Filter, Pagination, mobile CSS-Regeln, Accessibility-Checks, eine Option für TanStack Table und Playwright-Tests. Für den größeren React-Kontext passt React-Entwicklung mit Claude Code. Für Accessibility lies zusätzlich Barrierefreiheit mit Claude Code.
Offizielle Quellen sollten die Basis bleiben: MDN <table>, MDN aria-sort, TanStack Table, Playwright Writing tests und Claude Code overview.
Semantische Grundlagen
Nutze eine Tabelle, wenn Zeilen und Spalten gemeinsam Bedeutung erzeugen. Kundennamen, Plan, MRR, Status und Anmeldedatum sind tabellarische Daten, weil jeder Wert über die Spalte verstanden wird. Für unabhängige Karten ist eine Liste oder ein Grid oft besser. Für Vergleich über Spalten ist das native table die richtige Grundlage.
Die minimale Struktur besteht aus caption, thead, tbody und th scope="col". Wenn die erste Zelle eine Zeile identifiziert, verwende th scope="row".
<table>
<caption>Monatlich wiederkehrender Umsatz pro Kunde</caption>
<thead>
<tr>
<th scope="col">Kunde</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>
Diese Struktur sieht nicht automatisch spektakulär aus, ist aber die Grundlage für Screenreader, Tastaturbedienung und robuste Tests. Genau deshalb sollte sie in der Claude-Code-Anweisung stehen.
flowchart TD
A["Anforderungen"] --> B["Semantische Tabelle"]
B --> C["Sortieren, Filtern, Pagination"]
C --> D["Mobile Darstellung"]
D --> E["Accessibility Review"]
E --> F["Playwright Tests"]
Prompt für Claude Code
Der Prompt sollte Dateien, Datenform, Verhalten und Verbote nennen.
Baue eine Kundentabelle mit React + TypeScript.
Anforderungen:
- Nur src/components/DataTable.tsx und src/components/data-table.css ändern
- table, caption, thead, tbody und th scope verwenden
- Felder: id, name, plan, mrr, status, signedUpAt
- Globalen Filter, Spaltensortierung und Pagination mit 5 Zeilen ergänzen
- aria-sort nur auf der aktuell sortierten Spalte setzen
- Sortierung über button im Spaltenkopf auslösen
- Auf Mobile Spaltenlabels per data-label anzeigen
- Playwright-Test für Filter, Sortierung, Pagination und Mobile ergänzen
Nicht tun:
- Neue UI-Bibliothek hinzufügen
- role="grid" ohne passendes Tastaturmodell verwenden
- Pseudocode liefern
So behandelst du Claude Code nicht als Generator für schöne Markup-Fragmente, sondern als Assistenten für überprüfbare Änderungen.
Kopierbare React/TypeScript-Implementierung
Der folgende Code kommt ohne Tabellenbibliothek aus und passt für kleine bis mittlere Listen.
// 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>
);
}
Der wichtigste kleine Schutz ist setPage(1) beim Filtern. Ohne ihn kann eine gültige Suche leer wirken, wenn die Person vorher auf Seite 2 war.
Mobile CSS und Accessibility
Das CSS lässt die Tabelle im DOM und ändert nur die Darstellung unter 640px. Jede Zelle bekommt per data-label ihr Spaltenlabel.
.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;
}
}
Prüfe anschließend: Gibt es ein sinnvolles caption? Sind die Spaltenköpfe th? Hat nur die sortierte Spalte aria-sort? Ist die Sortierung ein echter button? Hat das Suchfeld ein Label? Wird die Seitenzahl mit aria-live aktualisiert? role="grid" sollte nur genutzt werden, wenn auch das passende Grid-Verhalten umgesetzt wird.
Wann TanStack Table sinnvoll ist
Für kleine Listen reicht die eigene Implementierung. TanStack Table lohnt sich bei Spaltensichtbarkeit, Spaltenfiltern, Zeilenauswahl, serverseitiger Pagination, festen Spalten oder Virtualisierung. Die Bibliothek ist headless: Sie verwaltet Logik, nicht das Design.
| Option | Geeignet für | Achtung |
|---|---|---|
| Eigene Komponente | Wenige Spalten, einfacher Filter | Jede neue Funktion bleibt bei dir |
| TanStack Table | Komplexer Zustand, Serverdaten, Auswahl | API lernen, UI selbst bauen |
| Enterprise Grid | Tabellenartige Bearbeitung, sehr große Daten | Gewicht, Setup, Lizenz |
Claude Code sollte begründen, warum die Abhängigkeit nötig ist. Für eine einmalige Einstellungsseite wäre sie oft zu viel.
Playwright-Prüfung
Teste den Arbeitsfluss: Tabelle sichtbar, Sortieren, Filtern, Pagination und Mobile-Labels.
// 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();
});
Bei einer Korrektur sollte Claude Code diesen Test zuerst fehlschlagen sehen und dann die Implementierung reparieren.
Use Cases, Fehler und Monetarisierung
| Use Case | Tabellenfunktionen | Wert |
|---|---|---|
| SaaS-Kundenliste | Plan, MRR, Status, Verlängerung | Churn-Risiko und Upsell erkennen |
| E-Commerce-Katalog | Bestand, Preis, Kategorie, Status | Fehler vor dem Verkauf finden |
| Content-Dashboard | PV, Leserate, CTA-Klicks, Update-Datum | SEO- und Werbeerlöse priorisieren |
| Rechnungen | Status, Betrag, Fälligkeit | Supportaufwand senken |
Typische Fehler sind div-Tabellen, Sortiericons ohne echten Zustand, fehlendes Zurücksetzen auf Seite 1, Mobile nur als nachträglicher Hack und unnötige UI-Bibliotheken. Schreibe diese Grenzen in den Prompt.
Tabellen helfen auch bei Monetarisierung, weil sie die nächste Aktion sichtbar machen. Ein Content-Team sieht Traffic, CTA-Klicks und Umsatz pro Artikel. Ein SaaS-Team sieht MRR, Nutzungsabfall und Renewal-Datum. Für einen wiederholbaren Teamprozess bietet sich Claude Code Training und Beratung an. Für eigenes Lernen starte mit Produkten und Templates.
Getestetes Ergebnis
Masa hat diese Struktur in einer kleinen Kundenliste ausprobiert. Der wichtigste Effekt war das Zurücksetzen der Pagination beim Filtern; vorher konnte eine Suche leer aussehen, obwohl Treffer existierten. Der zweite Effekt war data-label von Anfang an, statt mobile CSS später anzukleben. Claude Code liefert zuverlässiger, wenn Semantik, Zustand, Mobile, Accessibility und Playwright gemeinsam beauftragt werden.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.