Komplexe Tabellen ohne Pain
Datentabellen in React scheitern auf zwei Arten: Entweder baut man alles von Hand und investiert Wochen in Sorting, Filtering und Pagination, oder man wählt eine High-Level-Bibliothek und kämpft gegen ihren Lock-in. TanStack Table v8 ist der dritte Weg: ein headless Tabellen-Engine, der alle Logik liefert und das UI vollständig in eurer Hand lässt.
Inhaltsverzeichnis
- 1. Das Headless-Prinzip verstehen
- 2. Grundsetup: useReactTable und Spaltendefinitionen
- 3. Sorting: clientseitig und serverseitig
- 4. Column Filtering und globale Suche
- 5. Pagination: clientseitig und serverseitig
- 6. Column Pinning, Resizing und Hiding
- 7. Row Selection und Bulk Actions
- 8. Virtualisierung mit TanStack Virtual
- 9. TanStack Table vs. AG Grid vs. MUI DataGrid
- 10. Zusammenfassung
- 11. FAQ
1. Das Headless-Prinzip verstehen
Das Headless-Prinzip von TanStack Table bedeutet: Die Bibliothek liefert Zustand, Logik und Berechnungen – aber kein einziges HTML-Element, keine CSS-Klassen, kein DOM. Das klingt zunächst wie mehr Arbeit, ist aber der entscheidende Unterschied zu Bibliotheken wie AG Grid oder MUI DataGrid: Es gibt keinen Lock-in auf ein bestimmtes Styling-System, kein Kämpfen gegen vorgegebene Klassen und keine Abhängigkeit von fremden Themes. TanStack Table v8 rendern heißt: man bekommt Daten und Methoden, baut das <table>-Element selbst und hat absolute Kontrolle über Semantik, Styling und Accessibility.
Der Kern ist der useReactTable()-Hook. Er nimmt Daten, Spaltendefinitionen und Feature-Konfigurationen entgegen und gibt ein table-Objekt zurück. Dieses Objekt enthält Methoden zum Abrufen von Zeilen, Spalten, Header-Gruppen und allen Zuständen (Sort-Richtung, aktive Filter, aktuelle Seite). Das Rendering läuft über table.getHeaderGroups(), table.getRowModel().rows und flexRender(). Dieser Render-Aufruf ist die Brücke zwischen der Tabellen-Engine und dem React-Rendering – er nimmt einen Cell-Renderer (Funktion oder Komponente) und die Cell-Props entgegen.
Ein weiterer Kernunterschied: TanStack Table v8 ist framework-agnostisch. Derselbe Core läuft auch mit Vue, Solid und Angular. Für React gibt es einen dünnen Adapter-Layer, der den Core-Zustand mit React-State-Management verknüpft. Das bedeutet: Konzepte, die man in TanStack Table lernt, sind auf andere Frameworks übertragbar – ein langfristiger Vorteil in Teams, die mehrere Frontend-Technologien einsetzen.
2. Grundsetup: useReactTable und Spaltendefinitionen
Spaltendefinitionen sind der wichtigste Baustein einer TanStack Table-Implementierung. Sie werden mit createColumnHelper<DataType>() typisiert erstellt. Der columnHelper.accessor()-Aufruf nimmt den Datenzugriffsschlüssel (oder eine Funktion für abgeleitete Werte) und ein Options-Objekt. In den Options liegt alles, was die Spalte beschreibt: header für den Spaltenname, cell für das Cell-Rendering, sortingFn für benutzerdefiniertes Sorting und filterFn für benutzerdefiniertes Filtering. TypeScript leitet aus dem Datentyp automatisch ab, welche Accessor-Keys valide sind.
Die Spaltendefinitionen sind stabil-referenziert außerhalb der Komponente oder in einem useMemo. Das ist wichtig: Wenn die Spaltendefinitionen bei jedem Render neu erstellt werden, löst das unnötige Re-Renders der gesamten Tabelle aus. getCoreRowModel() ist die minimale Row Model Factory, die jede Tabelle braucht. Die weiteren Feature-Row-Models (getSortedRowModel, getFilteredRowModel, getPaginationRowModel) werden nur hinzugefügt, wenn die entsprechenden Features aktiviert sind.
// ProductTable.tsx — basic TanStack Table v8 setup with TypeScript
import {
useReactTable,
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
flexRender,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
interface Product {
id: number;
name: string;
category: string;
price: number;
stock: number;
}
// Column helper provides type-safe accessor and display column creation
const columnHelper = createColumnHelper<Product>();
// Define columns outside component to maintain stable reference
const columns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => <span className="font-mono text-slate-500">#{info.getValue()}</span>,
enableSorting: false,
}),
columnHelper.accessor("name", {
header: "Produktname",
cell: (info) => <span className="font-semibold">{info.getValue()}</span>,
}),
columnHelper.accessor("price", {
header: "Preis",
cell: (info) =>
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
.format(info.getValue()),
sortingFn: "basic",
}),
columnHelper.accessor("stock", {
header: "Lagerbestand",
cell: (info) => (
<span className={info.getValue() < 10 ? "text-red-600 font-bold" : ""}>
{info.getValue()}
</span>
),
}),
// Display column: no data accessor, custom content
columnHelper.display({
id: "actions",
header: "Aktionen",
cell: ({ row }) => (
<button onClick={() => console.log("edit", row.original.id)}>
Bearbeiten
</button>
),
}),
];
export function ProductTable({ data }: { data: Product[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table className="w-full text-sm border-collapse">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="bg-slate-900 text-white">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="text-left p-4 font-semibold cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{/* Sort indicator */}
{ { asc: " ↑", desc: " ↓" }[header.column.getIsSorted() as string] ?? ""}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
3. Sorting: clientseitig und serverseitig
Clientseitiges Sorting in TanStack Table ist mit getSortedRowModel() in wenigen Zeilen aktiv. Der Zustand liegt in SortingState – einem Array von { id: string; desc: boolean }-Objekten. Multi-Sorting wird durch enableMultiSort: true aktiviert; Nutzer sortieren dann mit Shift+Klick nach mehreren Spalten gleichzeitig. sortingFn pro Spalte erlaubt benutzerdefinierte Sortieralgorithmen: alphanumerisch, case-insensitive, nach Datum oder nach benutzerdefinierter Logik.
Serverseitiges Sorting erfordert manualSorting: true und keinen getSortedRowModel(). Der Sort-Zustand fließt über TanStack Query als Query-Parameter an das Backend: queryKey: ['products', { sorting }]. Jede Änderung des Sort-Zustands löst automatisch einen neuen Request aus. Dieses Muster kombiniert die Stärken beider Bibliotheken: TanStack Table verwaltet den UI-Zustand, TanStack Query übernimmt den Datenabruf. Das Ergebnis ist eine vollständig serverseitig sortierte und gepaginierte Tabelle ohne eigene State-Management-Logik.
4. Column Filtering und globale Suche
Column Filtering in TanStack Table v8 arbeitet auf zwei Ebenen: globales Filtering (globalFilter) durchsucht alle Spalten gleichzeitig, Column-level Filtering (columnFilters) filtert nach einzelnen Spalten mit spalteneigenen Logiken. Das globale Filter-Eingabefeld ist typischerweise eine Suche, die über table.setGlobalFilter(value) gesteuert wird. Debouncing des Input-Events verhindert, dass bei jedem Tastenanschlag neu gefiltert wird.
Die filterFn-Option pro Spalte kann auf vordefinierte Funktionen ("includesString", "equalsString", "inNumberRange", "arrIncludes") oder auf eigene Filterlogik verweisen. Für benutzerdefinierte Filter-UIs – Checkboxen für Kategorien, Datums-Picker, Range-Slider – rendert man das Filter-Element direkt über column.getFilterValue() und column.setFilterValue(). Alle Filter-Zustände können für URL-Synchronisation in den Search Params des TanStack Routers gespeichert werden, was die Tabellenansicht bookmarkfähig macht.
5. Pagination: clientseitig und serverseitig
Clientseitige Pagination mit getPaginationRowModel() funktioniert auf dem bereits gefilterten und sortierten Datenset. Der Pagination-Zustand enthält pageIndex und pageSize. table.nextPage(), table.previousPage(), table.setPageSize() und table.setPageIndex() steuern die Navigation. table.getCanNextPage() und table.getCanPreviousPage() steuern den Disabled-Zustand von Buttons. Die Gesamtseitenanzahl steht über table.getPageCount() zur Verfügung.
Serverseitige Pagination erfordert manualPagination: true und die Angabe von rowCount (Gesamtanzahl aller Datensätze vom Server). Der Pagination-Zustand wird wie der Sort-Zustand als Query-Parameter übergeben. Das Muster aus allen drei Features kombiniert – serverseitiges Sorting, Filtering und Pagination – ergibt eine vollständige Enterprise-Tabelle, bei der der Client nur die aktuelle Seite speichert und das Backend alle Berechnungen übernimmt. TanStack Table v8 verwaltet konsistent den UI-Zustand, ohne Datenbanklogik im Frontend zu replizieren.
// ServerTable.tsx — server-side sorting, filtering and pagination
import { useReactTable, getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { columns } from "./columns";
interface PagedResponse<T> {
data: T[];
rowCount: number;
}
export function ServerProductTable() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const [globalFilter, setGlobalFilter] = useState("");
// Fetch only the current page from the server
const { data, isLoading } = useQuery({
queryKey: ["products", "server-table", { pagination, globalFilter }],
queryFn: async (): Promise<PagedResponse<Product>> => {
const params = new URLSearchParams({
page: String(pagination.pageIndex + 1),
limit: String(pagination.pageSize),
...(globalFilter && { search: globalFilter }),
});
const res = await fetch(`/api/products?${params}`);
return res.json();
},
placeholderData: (prev) => prev, // keep old data while next page loads
});
const table = useReactTable({
data: data?.data ?? [],
columns,
rowCount: data?.rowCount, // needed for page count calculation
state: { pagination, globalFilter },
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, // server handles pagination
manualFiltering: true, // server handles filtering
});
return (
<div>
<input
value={globalFilter}
onChange={(e) => { setGlobalFilter(e.target.value); setPagination((p) => ({ ...p, pageIndex: 0 })); } }
placeholder="Produkte suchen…"
className="mb-4 w-full border rounded-lg px-4 py-2"
/>
{/* Table rendering with table.getRowModel().rows */}
<div className="flex items-center gap-2 mt-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Zurück</button>
<span>Seite {table.getState().pagination.pageIndex + 1} von {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Weiter</button>
</div>
</div>
);
}
6. Column Pinning, Resizing und Hiding
Column Pinning erlaubt es, Spalten am linken oder rechten Rand der Tabelle zu fixieren, während der Rest horizontal scrollt. Das ist unverzichtbar für breite Tabellen mit einer ID- oder Name-Spalte, die immer sichtbar bleiben soll. In TanStack Table v8 wird das über column.pin('left') oder column.pin('right') gesteuert. Für das CSS-Layout müssen gepinnte Spalten mit position: sticky und dem korrekt berechneten left- oder right-Offset positioniert werden. Der Offset wird über column.getStart('left') und column.getAfter('right') berechnet.
Column Resizing aktiviert man mit enableColumnResizing: true und einem columnResizeMode ("onChange" für Echtzeit-Resize oder "onEnd" für Resize erst beim Loslassen der Maus). Jeder Column-Header bekommt einen Resize-Handle-Div, der die column.getResizeHandler()-Funktion als onMouseDown-Handler nutzt. Column Hiding ist am einfachsten: column.getIsVisible() und column.toggleVisibility() steuern die Sichtbarkeit. Ein Dropdown mit Checkboxen für jede Spalte ist schnell implementiert und erhöht die Nutzbarkeit bei breiten Tabellen erheblich.
7. Row Selection und Bulk Actions
Row Selection in TanStack Table v8 wird über eine Display-Spalte mit Checkboxen implementiert. Der Zustand ist RowSelectionState – ein Objekt mit Row-IDs als Keys und true als Wert. table.getSelectedRowModel().rows gibt alle selektierten Zeilen zurück, table.getIsAllPageRowsSelected() steuert die Header-Checkbox. Das "Alle auswählen"-Verhalten ist konfigurierbar: entweder alle Seiten oder nur die aktuelle Seite selektieren.
Bulk Actions – Löschen, Exportieren, Statusänderung für mehrere Zeilen – bauen direkt auf dem Row Selection State auf. Ein Aktions-Panel erscheint, wenn Object.keys(rowSelection).length > 0 ist, und zeigt die Anzahl ausgewählter Zeilen sowie die verfügbaren Aktionen. Die Mutation nutzt table.getSelectedRowModel().rows.map(row => row.original), um die vollständigen Datensätze der selektierten Zeilen zu extrahieren. Nach der Mutation: table.resetRowSelection() setzt die Selektion zurück und queryClient.invalidateQueries() aktualisiert den Cache.
8. Virtualisierung mit TanStack Virtual
Bei Tabellen mit tausenden Zeilen wird DOM-Performance zum Problem: Jede Zeile als <tr>-Element zu rendern führt zu langen Scroll-Jank und hohem Memory-Verbrauch. TanStack Virtual (ehemals react-virtual) löst das durch Virtualisierung: Nur die im Viewport sichtbaren Zeilen werden gerendert, der Rest wird durch leere Platzhalter-Bereiche simuliert. Die Integration mit TanStack Table ist direkt: Man ersetzt table.getRowModel().rows durch den virtualisierten Subset von rowVirtualizer.getVirtualItems().
Die Implementierung erfordert einen Container mit fester Höhe und overflow-y: auto, einen Ref auf diesen Container für den Virtualizer, und CSS für die Platzhalter-Bereiche (paddingTop und paddingBottom auf dem <tbody>). Mit diesem Setup rendert eine Tabelle mit 100.000 Zeilen genauso flüssig wie eine mit 100 – weil immer nur 20–50 DOM-Elemente existieren. Die Scroll-Performance bleibt konstant unabhängig von der Datenmenge, was für Admin-Interfaces und Datenverwaltungstools entscheidend ist.
9. TanStack Table vs. AG Grid vs. MUI DataGrid
Die Entscheidung für eine Tabellenbibliothek hängt stark vom Anwendungsfall ab. Der direkte Vergleich zeigt, wo TanStack Table gewinnt und wo es Grenzen gibt.
| Aspekt | AG Grid Community | MUI DataGrid | TanStack Table v8 |
|---|---|---|---|
| Styling-Freiheit | Eingeschränkt (eigene Theme-API) | MUI-abhängig | Absolut frei, eigenes HTML |
| Setup-Aufwand | Gering (Out-of-box) | Gering (Out-of-box) | Mittel (eigenes Rendering) |
| TypeScript | Partiell | Partiell | Vollständig, Generics |
| Bundle-Größe | ~300 KB (Community) | ~150 KB + MUI | ~15 KB (headless core) |
| Virtualisierung | Eingebaut | Pro-Feature | TanStack Virtual separat |
AG Grid und MUI DataGrid sind die richtige Wahl, wenn das Team schnell eine funktionale Tabelle ohne viel Eigenleistung braucht und mit dem eingebauten Styling leben kann. TanStack Table v8 ist die richtige Wahl, wenn vollständige Styling-Kontrolle, minimale Bundle-Größe und maximale TypeScript-Typsicherheit wichtig sind. Der Initialaufwand ist höher, aber das Ergebnis ist eine Tabelle ohne jegliche fremde CSS-Abhängigkeiten.
Mironsoft
React Datentabellen, TanStack Table und Admin-Interfaces
Komplexe Tabellen ohne Styling-Lock-in?
Wir implementieren TanStack Table v8 in euren React-Projekten – mit Sorting, Filtering, Pagination, Column Pinning und TanStack Virtual für reibungsloses Scrollen durch 100.000 Zeilen.
Tabellenaudit
Bestehende Tabellenimplementierungen analysieren und Migrationspfad auf TanStack Table v8 definieren
Custom-Tabelle
Maßgeschneiderte Tabellen-Komponente mit eurem Design-System, Tailwind und allen benötigten Features
Server-Integration
Serverseitiges Sorting, Filtering und Pagination mit TanStack Query und eurer API-Schicht
10. Zusammenfassung
TanStack Table v8 ist das mächtigste headless Tabellen-Framework für React. Das Headless-Prinzip gibt absolute Styling-Kontrolle ohne CSS-Lock-in. Typisierte Spaltendefinitionen mit createColumnHelper<T>() machen Tabellen zum vollständig TypeScript-verifizierten Bestandteil der Codebase. Clientseitiges und serverseitiges Sorting, Filtering und Pagination sind über einfache Feature-Flags umschaltbar. Column Pinning, Resizing und Hiding sind eingebaut. Row Selection mit Bulk Actions folgt einem klaren, vorhersagbaren Muster. TanStack Virtual ergänzt um Virtualisierung für sehr große Datensätze.
Der größte Vorteil gegenüber High-Level-Bibliotheken: Mit TanStack Table ist das Ergebnis eine vollständig eigene Tabellen-Komponente, die mit dem Design System des Projekts übereinstimmt und keine externen CSS-Abhängigkeiten hat. Keine Kämpfe gegen fremde Theme-APIs, kein Inline-Overriding von Bibliotheks-Styles, keine Einschränkungen bei der Accessibility. Der Initialaufwand lohnt sich ab dem ersten Projekt, das mehr als einfaches Sortieren und einfaches Paginieren braucht.
TanStack Table v8 — Das Wichtigste auf einen Blick
Headless-Prinzip
Kein HTML, kein CSS von der Bibliothek. Vollständige Kontrolle über Markup und Styling. Kein Lock-in auf fremde Themes oder UI-Systeme.
Spaltendefinitionen
createColumnHelper<T>() für typsichere Accessor- und Display-Spalten. Außerhalb der Komponente definieren für stabile Referenz.
Server-seitig
manualSorting/Filtering/Pagination: true + TanStack Query. Zustand als Query-Key, automatischer Refetch bei Änderung.
Virtualisierung
TanStack Virtual für 100.000+ Zeilen. Nur sichtbare DOM-Elemente. Konstante Scroll-Performance unabhängig von Datenmenge.