Tips & Tricks (Aktualisiert: 2.6.2026)

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.

React-Tabellenkomponente mit Claude Code bauen: Sortieren, Filtern, Pagination

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.

OptionGeeignet fürAchtung
Eigene KomponenteWenige Spalten, einfacher FilterJede neue Funktion bleibt bei dir
TanStack TableKomplexer Zustand, Serverdaten, AuswahlAPI lernen, UI selbst bauen
Enterprise GridTabellenartige Bearbeitung, sehr große DatenGewicht, 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 CaseTabellenfunktionenWert
SaaS-KundenlistePlan, MRR, Status, VerlängerungChurn-Risiko und Upsell erkennen
E-Commerce-KatalogBestand, Preis, Kategorie, StatusFehler vor dem Verkauf finden
Content-DashboardPV, Leserate, CTA-Klicks, Update-DatumSEO- und Werbeerlöse priorisieren
RechnungenStatus, Betrag, FälligkeitSupportaufwand 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.

#Claude Code #table #React #TanStack Table #UI
Kostenlos

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.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.