Zum Inhalt springen
    WingoUI
    KomponentenVorlagenDokuPreiseKI-AgentenBlogChangelog
    1. Startseite
    2. Blog
    3. Komponenten-Guides
    4. TanStack Table v9: Data Table mit Filtern und Bulk Actions
    Read in English
    1. Blog
    2. Komponenten-Guides
    3. TanStack Table v9: Data Table mit Filtern und Bulk Actions
    Komponenten-Guides
    Komponenten-Guides

    TanStack Table v9: Data Table mit Filtern und Bulk Actions

    SR
    Serban Rusu · Gründer von Wingo UI
    9. Okt. 2026 · 12 Min. Lesezeit

    Auf dieser Seite

    0%
    1. Was hat sich in TanStack Table v9 für eine Datentabelle geändert?
    2. Wie richtest du TanStack Table v9 mit Filtern, Auswahl und Pinning ein?
    3. Wie bekommst du Filter, Bulk Actions und Pinning, ohne die UI selbst zu bauen?
      1. Welcher Filter passt zu welcher Spalte?
    4. Wie sollten Bulk Actions über mehrere Seiten funktionieren?
    5. Wie richtest du Server-Side Pagination in TanStack Table ein?
    6. Wie sollte eine Datentabelle auf dem Smartphone aussehen?
    7. Solltest du direkt auf TanStack bauen oder eine Komponente nutzen?
    8. Was solltest du als Nächstes bauen?
    9. Häufige Fragen
      1. Was ersetzt useReactTable in TanStack Table v9?
      2. Wie funktioniert Server-Side Pagination in TanStack Table v9?
      3. Wie pinne ich Spalten in TanStack Table v9?
      4. Funktioniert die shadcn Data Table auf dem Smartphone?
      5. Ist die Data Table von Wingo UI kostenlos?

    Kurz gesagt

    Eine Data Table auf TanStack Table v9 beginnt mit tableFeatures: Du registrierst Sortierung, Filter, Pagination, Pinning und Auswahl einmal auf Modulebene und gibst sie an useTable, das useReactTable ersetzt. Für Server-Pagination setzt du manualPagination, manualSorting und manualFiltering und übergibst einen rowCount aus deiner API; das Pinning von Spalten heißt jetzt start und end statt left und right. Die Bibliothek liefert nur State und Row Models; Filter-UI, Bulk-Leiste und Smartphone-Layout baust oder installierst du weiterhin selbst.

    Veröffentlicht am 9. Okt. 2026

    TanStack Table v9 ist seit August 2026 stabil, und viele Tutorials zu Datentabellen zeigen noch die v8-API: useReactTable, getCoreRowModel() und columnPinning.left. Kopierst du so eins in ein v9-Projekt, scheitert der Import, weil es useReactTable nicht mehr gibt. Wechselst du zu useTable, sortiert nichts, bis du das Sortier-Feature registrierst. Dieses Tutorial baut eine Admin-Datentabelle auf TanStack Table v9: Spaltenfilter, Bulk Actions für ausgewählte Zeilen, gepinnte Spalten und Server-Pagination, dazu das Kartenlayout, auf das sie auf dem Smartphone wechseln sollte. Die erste Hälfte ist reines TanStack und funktioniert mit jedem Markup. Die zweite Hälfte nutzt die Data Table von Wingo UI, die wir selbst bauen. Unsere Einschätzungen sind also nicht neutral.

    Was hat sich in TanStack Table v9 für eine Datentabelle geändert?

    Features sind jetzt explizit. Du deklarierst Sortierung, Filter, Pagination, Pinning und Auswahl in einem tableFeatures()-Objekt, und die Row-Model-Factories liegen in diesem Objekt statt in den Tabellenoptionen. Die Umbenennungen, die eine Datentabelle betreffen, laut Migrationsguide für React (opens in a new tab), Stand Oktober 2026:

    v8v9
    useReactTable(options)useTable(options, selector?) mit der Pflichtoption features
    getCoreRowModel: getCoreRowModel()automatisch
    getSortedRowModel: getSortedRowModel()sortedRowModel: createSortedRowModel() in tableFeatures
    columnPinning: { left, right }, column.pin("left")columnPinning: { start, end }, column.pin("start")
    sortingFn, sortingFnssortFn, sortFns
    table.getState()table.state, oder table.Subscribe für einen einzelnen Ausschnitt
    createColumnHelper<Person>()createColumnHelper<typeof features, Person>()

    Drei Verhaltensänderungen werden bei einer Migration zur Falle. In 9.2.4 kommt row.getVisibleCells() aus columnVisibilityFeature. Eine Tabelle ohne dieses Feature hat nur getAllCells(), und das ignoriert die Reihenfolge des Pinnings. getIsSomeRowsSelected() liefert auch dann true, wenn alle Zeilen ausgewählt sind, also braucht eine Header-Checkbox im Zustand „indeterminate“ getIsSomeRowsSelected() && !getIsAllRowsSelected(). Und row.getToggleSelectedHandler() wählt bei Shift-Klick standardmäßig einen Bereich aus (enableRowRangeSelection: false schaltet das ab).

    Laut Ankündigung von v9 (opens in a new tab) (4. August 2026) ist das komplette Paket von 14kb auf 25kb gewachsen, und Features, die du nicht registrierst, bleiben aus dem Bundle. Registrier also, was die Tabelle nutzt, und sonst nichts.

    Wie richtest du TanStack Table v9 mit Filtern, Auswahl und Pinning ein?

    Bau das features-Objekt und die Spalten einmal auf Modulebene und ruf dann useTable mit den Daten auf. Die Doku von TanStack verlangt stabile features, columns und data, und ein neues data-Array bei jedem Render macht jedes Row Model ungültig, das daraus gebaut wurde. Hier ein vollständiges TanStack Table v9 Beispiel für eine Kundenliste:

    tsx
    // clients-grid.tsx
    "use client";
    import type { CSSProperties } from "react";
    import {
    columnFilteringFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature,
    createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel,
    filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature,
    rowSortingFeature, sortFn_alphanumeric, sortFn_basic, tableFeatures, useTable, type Column,
    } from "@tanstack/react-table";
    export type Client = { id: string; name: string; city: string; status: string; balance: number };
    const features = tableFeatures({
    rowSortingFeature,
    sortedRowModel: createSortedRowModel(),
    sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
    columnFilteringFeature,
    filteredRowModel: createFilteredRowModel(),
    filterFns: { includesString: filterFn_includesString, arrIncludesSome: filterFn_arrIncludesSome },
    rowPaginationFeature,
    paginatedRowModel: createPaginatedRowModel(),
    columnSizingFeature,
    columnPinningFeature,
    columnVisibilityFeature,
    rowSelectionFeature,
    });
    const helper = createColumnHelper<typeof features, Client>();
    const columns = helper.columns([
    helper.display({
    id: "select",
    size: 40,
    header: ({ table }) => (
    <input
    type="checkbox"
    aria-label="Seite auswählen"
    checked={table.getIsAllPageRowsSelected()}
    // v9: "some" bleibt true, wenn alle ausgewählt sind
    ref={(el) => {
    if (el) el.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected();
    }}
    onChange={table.getToggleAllPageRowsSelectedHandler()}
    />
    ),
    cell: ({ row }) => (
    <input type="checkbox" aria-label="Zeile auswählen" checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />
    ),
    }),
    helper.accessor("name", { header: "Kunde", size: 220, filterFn: "includesString", sortFn: "alphanumeric" }),
    helper.accessor("city", { header: "Stadt", filterFn: "arrIncludesSome" }),
    helper.accessor("status", { header: "Status", filterFn: "arrIncludesSome" }),
    helper.accessor("balance", { header: "Saldo", sortFn: "basic" }),
    ]);
    // feste Breiten halten die Sticky-Offsets korrekt; gepinnte Zellen brauchen einen Hintergrund
    function cellStyle(column: Column<typeof features, Client, unknown>): CSSProperties {
    const width = column.getSize();
    const side = column.getIsPinned();
    if (side === "start") return { width, position: "sticky", insetInlineStart: column.getStart("start"), zIndex: 1, background: "Canvas" };
    if (side === "end") return { width, position: "sticky", insetInlineEnd: column.getAfter("end"), zIndex: 1, background: "Canvas" };
    return { width };
    }
    export function ClientsGrid({ data }: { data: Client[] }) {
    const table = useTable({
    features,
    columns,
    data,
    getRowId: (row) => row.id,
    initialState: { columnPinning: { start: ["select", "name"], end: [] } },
    });
    return (
    <div style={{ overflowX: "auto" }}>
    <table style={{ tableLayout: "fixed", width: table.getTotalSize() }}>
    <thead>
    {table.getHeaderGroups().map((group) => (
    <tr key={group.id}>
    {group.headers.map((header) => (
    <th key={header.id} style={cellStyle(header.column)}>
    {header.column.getCanSort() ? (
    <button type="button" onClick={header.column.getToggleSortingHandler()}>
    <table.FlexRender header={header} />
    </button>
    ) : (
    <table.FlexRender header={header} />
    )}
    </th>
    ))}
    </tr>
    ))}
    </thead>
    <tbody>
    {table.getRowModel().rows.map((row) => (
    <tr key={row.id}>
    {row.getVisibleCells().map((cell) => (
    <td key={cell.id} style={cellStyle(cell.column)}>
    <table.FlexRender cell={cell} />
    </td>
    ))}
    </tr>
    ))}
    </tbody>
    </table>
    </div>
    );
    }

    Das feste Layout und die Breiten sind wichtig. getStart("start") liefert für die Namensspalte 40, die size der Auswahlspalte. Der Browser muss diese Spalte also genau 40px breit zeichnen, sonst bleibt die gepinnte Namensspalte mit einer Lücke davor kleben. Canvas ist der Seitenhintergrund im aktiven Farbschema; setz dort dein eigenes Token ein.

    Die Filterfunktionen sind über ihren Namen registriert, also wird filterFn: "arrIncludesSome" gegen die Registry typgeprüft. Filtern und Sammelaktionen laufen über dieselbe Instanz:

    ts
    table.getColumn("status")?.setFilterValue(["Overdue"]);
    const ids = table.getSelectedRowModel().rows.map((row) => row.original.id);

    Mehr macht die Bibliothek nicht: State, Row Models und Handler. Alles, was ein Mensch sieht, fehlt noch: ein Suchfeld, Filtermenüs mit Checkboxen und Bereichen, Chips für die aktiven Filter, eine Leiste, die bei ausgewählten Zeilen erscheint, Tastaturnavigation zwischen den Zeilen und ein Layout für 390px breite Screens. Wenn du mit dem Guide zur shadcn Data Table angefangen hast: Er ist für TanStack Table v9 geschrieben (opens in a new tab), Stand Oktober 2026, und ergänzt Sortierung, einen Textfilter, Spaltensichtbarkeit, Client-Pagination, ein Menü für Zeilenaktionen und die Zahl der ausgewählten Zeilen. Server-Pagination, Pinning, Bulk Actions und ein Smartphone-Layout stehen nicht drin.

    Wie bekommst du Filter, Bulk Actions und Pinning, ohne die UI selbst zu bauen?

    Installier eine Komponente, die dieselbe Art TanStack-Tabelle baut und diese Teile rendert. Unsere Data Table läuft auf TanStack Table v9, registriert ihre Features einmal auf Modulebene und nimmt einfache Spaltenobjekte:

    bash
    npx wingo-ui@latest login # einmal, mit einem Pro-Account
    npx wingo-ui@latest add data-table
    tsx
    // clients-table.tsx
    "use client";
    import { useState } from "react";
    import { useRouter } from "next/navigation";
    import { Archive, Download, Pencil, Plus, Trash2 } from "lucide-react";
    import { DataTable, createColumns } from "@/components/ui/data-table";
    // deine Server Actions; jede bekommt die ausgewählten IDs
    import { archiveClients, deleteClients, exportClients } from "./actions";
    export type Client = {
    id: string;
    name: string;
    taxId: string;
    city: string;
    status: "Active" | "Overdue" | "Prospect";
    owner: string;
    balance: number;
    lastInvoice: string | null;
    };
    const col = createColumns<Client>();
    export const columns = col([
    col.accessor("name", { header: "Kunde", filter: "text", pinned: "left", mobile: "title" }),
    col.accessor("city", { header: "Stadt", filter: "multi-select", mobile: "subtitle" }),
    col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
    filter: "multi-select",
    mobile: "meta",
    }),
    col.accessor("owner", { header: "Betreuer", type: "avatar", filter: "select" }),
    col.accessor("balance", { header: "Saldo", type: "currency", footer: "sum", filter: "number-range" }),
    col.accessor("lastInvoice", { header: "Letzte Rechnung", type: "relative", filter: "date-range" }),
    // standardmäßig ausgeblendet, die Suche findet sie trotzdem
    col.accessor("taxId", { header: "Steuernummer", searchable: true, mobile: "hidden" }),
    ]);
    export function ClientsTable({ clients }: { clients: Client[] }) {
    const router = useRouter();
    const [selected, setSelected] = useState<string[]>([]);
    return (
    <DataTable
    data={clients}
    columns={columns}
    caption="Kunden"
    locale="en-US"
    searchPlaceholder="Nach Name, Steuernummer oder Stadt suchen"
    defaultColumnVisibility={{ taxId: false }}
    quickFilters={[
    { id: "overdue", label: "Überfällig", columnFilter: { id: "status", value: ["Overdue"] } },
    { id: "big", label: "Über $10k", filter: (row) => row.balance > 10_000 },
    ]}
    selectionMode="multiple"
    selectedKeys={selected}
    onSelectedKeysChange={setSelected}
    bulkActions={(_rows, keys) => [
    { label: "Exportieren", icon: <Download />, onSelect: () => exportClients(keys) },
    { label: "Archivieren", icon: <Archive />, onSelect: () => archiveClients(keys).then(() => setSelected([])) },
    { label: "Löschen", icon: <Trash2 />, tone: "danger", onSelect: () => deleteClients(keys).then(() => setSelected([])) },
    ]}
    rowActions={(row) => [{ label: "Bearbeiten", icon: <Pencil />, href: `/clients/${row.id}` }]}
    toolbarActions={[{ label: "Neuer Kunde", icon: <Plus />, primary: true, onSelect: () => router.push("/clients/new") }]}
    persistKey="clients"
    />
    );
    }

    createColumns<Client>() prüft jeden Accessor-Key gegen den Zeilentyp, und pinned: "left" wird im TanStack-State zu columnPinning.start. Über das Menü jedes Headers lässt sich eine Spalte auch zur Laufzeit pinnen, verschieben oder ausblenden, und persistKey merkt sich Sichtbarkeit, Reihenfolge, Breiten, Dichte und Seitengröße im Local Storage. Das rowActions-Menü bekommt eine am Ende gepinnte ⋯-Spalte und öffnet sich auch per Rechtsklick und mit Shift+F10 auf einer fokussierten Zeile.

    Wähl ein paar Zeilen aus, und die Toolbar wird zur Bulk-Leiste; öffne den Pfeil in einem Header, um die Spalte zu filtern oder zu pinnen, oder tipp auf den Chip „Overdue“. Unter 768px ist jede Zeile eine Karte: Drück lange auf eine, um die Auswahl zu starten
    $ npx wingo-ui@latest add data-table
    ProDoku: Data Table

    Welcher Filter passt zu welcher Spalte?

    Wähl den Filter nach der Frage, die Leute an die Spalte stellen, und kenn die Form des Werts, denn ein Server muss ihn parsen. Hier die React Data Table Filter mit den Werten, die onColumnFiltersChange und die Server-Query transportieren:

    filterGedacht fürWert
    textNamen, E-Mails, IDs"cafe"
    selectein Betreuer, ein Tarif"Emma Carter"
    multi-selectStatus, Städte, Tags["Active", "Overdue"]
    number-rangeSalden, Mengen[1000, null] (jedes Ende darf null sein)
    date-rangeRechnungsdaten, Anmeldungen{ from, to }, jeweils ein Date oder null (ISO-Strings nach dem JSON-Encoding)

    Im Client-Modus listen die Select-Filter die eindeutigen Werte der Spalte mit ihrer Anzahl, und das Suchfeld findet jedes Wort in allen durchsuchbaren Spalten, Akzente und formatierte Werte inklusive: „cafe“ findet Café, und „12480“ findet $12,480.00. Quick Filters sind Chips für die zwei oder drei Fragen, die jemand jeden Morgen stellt. Ein Chip mit columnFilter funktioniert in beiden Modi; ein Chip mit einer filter-Funktion läuft nur im Browser.

    Wie sollten Bulk Actions über mehrere Seiten funktionieren?

    Arbeite mit den IDs, nicht mit den Zeilenobjekten. bulkActions bekommt (rows, keys): keys enthält jede ausgewählte ID, auch Zeilen auf anderen Seiten oder solche, die ein Filter ausblendet, während rows nur die ausgewählten Zeilen enthält, die in data stehen. Im Server-Modus ist das die aktuelle Seite. Ein Export, der auf rows aufbaut, verliert also stillschweigend alles, was du auf Seite 1 ausgewählt hast, sobald du auf Seite 3 bist.

    Solange Zeilen ausgewählt sind, wechselt die Toolbar in ihren Bulk-Modus: ein Band in der Zeile auf dem Desktop, eine schwebende Leiste auf dem Smartphone. Der Rest funktioniert wie in Gmail. Wählst du im Client-Modus eine ganze Seite aus, bietet ein Banner alle passenden Zeilen an, und Zeilen, die ein neuer Filter ausblendet, bleiben ausgewählt, wobei das Banner sagt, wie viele es sind. Shift-Klick wählt einen Bereich, Strg oder Cmd + A tut dasselbe wie die Header-Checkbox, Escape hebt die Auswahl auf. Eine Sammelaktion, die ein Promise zurückgibt, zeigt ihren Button im Ladezustand, bis es abgeschlossen ist, und der Tone danger bleibt rot, auch in der schwebenden Leiste auf dem Smartphone. Für „240 Kunden löschen“ öffnen wir trotzdem einen Alert Dialog aus onSelect: Ein Massenlöschen verdient einen Tap mehr.

    Leere die Auswahl, nachdem eine Aktion Zeilen entfernt hat. Die Tabelle verwirft eine ausgewählte ID nie von selbst, weil eine fehlende ID auf einer anderen Seite stehen kann. Nach einem Löschen würde die Leiste also noch Zeilen zählen, die es nicht mehr gibt. Deshalb steuert das Beispiel oben selectedKeys selbst.

    Wie richtest du Server-Side Pagination in TanStack Table ein?

    Sag der Tabelle, dass der Server die Arbeit macht, gib ihr die Gesamtzahl und lade neu, sobald sich der State ändert. TanStack Table Server Side Pagination besteht in v9 aus drei Flags und einer Zahl. Die Row-Model-Factories können weg, weil der Browser nie sortiert oder in Seiten aufteilt:

    ts
    const serverFeatures = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature });
    // die Spalten kommen aus createColumnHelper<typeof serverFeatures, Client>()
    const NO_ROWS: Client[] = [];
    // in der Komponente: result ist dein Fetch für { pagination, sorting, columnFilters }
    const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
    const [sorting, setSorting] = useState<SortingState>([]);
    const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
    const table = useTable({
    features: serverFeatures,
    columns,
    data: result.data?.rows ?? NO_ROWS,
    rowCount: result.data?.total,
    state: { pagination, sorting, columnFilters },
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    manualPagination: true,
    manualSorting: true,
    manualFiltering: true,
    });

    Die Data Table packt diese Verdrahtung in mode="server": data ist die aktuelle Seite, rowCount die Gesamtzahl, und jede Änderung kommt in onQueryChange als ein Objekt mit page, pageSize, sorting, globalFilter und columnFilters an. Dieses Objekt ist ein fertiger Query Key für TanStack Query:

    tsx
    // server-clients-table.tsx
    "use client";
    import { useState } from "react";
    import { keepPreviousData, useQuery } from "@tanstack/react-query";
    import { Trash2 } from "lucide-react";
    import { DataTable, type DataTableQuery } from "@/components/ui/data-table";
    import { deleteClients } from "./actions";
    import { columns, type Client } from "./clients-table";
    // muss zu den Defaults der Tabelle passen: der erste Render ruft onQueryChange nicht auf
    const FIRST: DataTableQuery = { page: 1, pageSize: 25, sorting: [], globalFilter: "", columnFilters: [] };
    const NO_ROWS: Client[] = [];
    async function fetchClients(query: DataTableQuery): Promise<{ rows: Client[]; total: number }> {
    const res = await fetch(`/api/clients?q=${encodeURIComponent(JSON.stringify(query))}`);
    if (!res.ok) throw new Error(`Laden der Kunden fehlgeschlagen: ${res.status}`);
    return res.json();
    }
    export function ServerClientsTable() {
    const [query, setQuery] = useState(FIRST);
    const [selected, setSelected] = useState<string[]>([]);
    const { data, isPending, isFetching, error, refetch } = useQuery({
    queryKey: ["clients", query],
    queryFn: () => fetchClients(query),
    placeholderData: keepPreviousData,
    });
    return (
    <DataTable
    mode="server"
    data={data?.rows ?? NO_ROWS}
    rowCount={data?.total ?? 0}
    columns={columns}
    onQueryChange={setQuery}
    loading={isPending}
    refreshing={isFetching && !isPending}
    error={error ? "Die Kunden konnten nicht geladen werden." : undefined}
    onRetry={() => refetch()}
    selectionMode="multiple"
    selectedKeys={selected}
    onSelectedKeysChange={setSelected}
    bulkActions={(_rows, keys) => [
    {
    label: "Löschen",
    icon: <Trash2 />,
    tone: "danger",
    onSelect: async () => {
    await deleteClients(keys);
    setSelected([]);
    await refetch();
    },
    },
    ]}
    caption="Kunden"
    locale="en-US"
    />
    );
    }

    keepPreviousData lässt die letzte Seite stehen, während die nächste lädt, und refreshing schiebt einen dünnen Balken über die Oberkante, statt die Zeilen gegen Skeletons zu tauschen. Die Suche kommt mit Debounce an (300ms), und die Seite springt auf 1 zurück, wenn sich Suche, ein Filter oder die Sortierung ändert.

    Der Route Handler behandelt die Query als nicht vertrauenswürdige Eingabe. Sortier-IDs und Seitengrößen kommen aus einer Whitelist, und jede Filter-ID wird auf eine Klausel abgebildet, die du geschrieben hast:

    ts
    // app/api/clients/route.ts
    import type { NextRequest } from "next/server";
    import type { DataTableQuery } from "@/components/ui/data-table";
    import { findClients } from "@/server/clients";
    const SORTABLE = new Set(["name", "city", "status", "owner", "balance", "lastInvoice"]);
    const PAGE_SIZES = [10, 25, 50, 100];
    export async function GET(request: NextRequest) {
    let raw: Partial<DataTableQuery> = {};
    try {
    raw = JSON.parse(request.nextUrl.searchParams.get("q") ?? "{}");
    } catch {}
    const pageSize = PAGE_SIZES.includes(Number(raw.pageSize)) ? Number(raw.pageSize) : 25;
    const page = Math.max(1, Math.floor(Number(raw.page)) || 1);
    const first = (Array.isArray(raw.sorting) ? raw.sorting : []).find((s) => SORTABLE.has(s?.id));
    const sort = first ? { id: first.id, desc: first.desc === true } : undefined;
    // findClients prüft die Session und bildet jede Filter-ID auf eine bekannte SQL-Klausel ab
    const { rows, total } = await findClients({
    offset: (page - 1) * pageSize,
    limit: pageSize,
    sort,
    search: String(raw.globalFilter ?? "").slice(0, 100),
    filters: Array.isArray(raw.columnFilters) ? raw.columnFilters : [],
    });
    return Response.json({ rows, total });
    }

    Zwei Details zum Server-Modus. Ohne die vollständigen Daten hat die Tabelle keine Facettenwerte. Ein Select-Filter listet deshalb nur seine filterOptions, oder bei einer Badge-Spalte die Keys der Badge-Map: status funktioniert so, wie es ist, während city und owner eine explizite filterOptions-Liste brauchen (zum Beispiel aus einem select distinct auf dem Server). Und Quick Filters brauchen einen columnFilter, weil eine filter-Funktion nie beim Server ankommt. Sollen Seite, Sortierung und Filter in der URL stehen, damit ein Link dieselbe Ansicht wieder öffnet, zeigt das Tutorial zum Next.js Admin Dashboard, wie das mit router.replace und einer Server Component geht.

    Sortier, such oder wähl Zeilen auf Seite 1 und dann auf Seite 2 aus (auf dem Smartphone mit langem Druck auf eine Karte): Das Query-Objekt, das der Server bekommt, steht unter der Tabelle, und die Auswahl zählt die Zeilen beider Seiten
    $ npx wingo-ui@latest add data-table
    ProDoku: Data Table

    Der Server-Modus hat seinen Preis: Jede Tipppause ist eine Anfrage. Wenn eine einzige Anfrage alle Zeilen laden kann, die ein Nutzer sehen darf, filtert der Client-Modus sofort und braucht keine Query-API. Wir wechseln ab ein paar tausend Zeilen zum Server-Modus, oder wenn Berechtigungen bestimmen, welche Zeilen für einen Nutzer existieren. Passen die Zeilen zwar in eine Anfrage, gehen aber in die Zehntausende, rendert das Tutorial zur React Virtualized Table mit TanStack Virtual nur die sichtbaren Zeilen.

    Wie sollte eine Datentabelle auf dem Smartphone aussehen?

    Eine Karte pro Zeile. Acht Spalten auf einem 390px breiten Screen zeigen etwa zwei auf einmal, und der Header, der sie benennt, scrollt aus dem Bild. Unter 768px rendert die Data Table jede Zeile als Karte, gebaut aus denselben mobile-Rollen, die du in den Spalten gesehen hast:

    • title ist die Überschrift der Karte (standardmäßig die erste Spalte), subtitle die Zeile darunter.
    • meta sitzt in der oberen Ecke (standardmäßig das erste Badge oder die letzte Zahlenspalte).
    • Bis zu vier field-Spalten erscheinen als Paare aus Label und Wert; hidden hält eine Spalte von der Karte fern.

    Drück lange auf eine Karte, um die Auswahl zu starten, tipp dann weitere an, und die Bulk-Leiste schwebt unten am Screen. Sortierung, Filter und das Zeilenmenü öffnen sich als Bottom Sheets. Der Client-Modus hängt Zeilen mit „Load more“ an, der Server-Modus behält einen kompakten Pagination-Pager. Der Wechsel ist ein CSS-Breakpoint, also hat das Server-HTML schon vor der Hydration das richtige Layout, und nichts springt. renderCard ersetzt die Zuordnung durch deine eigene Karte, und mobileLayout="scroll" lässt die Tabelle seitwärts scrollen, für das seltene Grid, das Leute Spalte für Spalte vergleichen. Die Begründung für diese Defaults, mit 44px Touch Targets und Bottom Sheets, steht in Mobile-first React-Komponenten. Wie du die Spalten für eine Karte auswählst und dasselbe Layout auf einer einfachen shadcn/ui-Tabelle baust, steht in responsive Tabellen auf dem Smartphone.

    Für kleine Tabellen zum Lesen, etwa Rechnungspositionen oder eine Preisliste, macht die einfache Table denselben Wechsel auf Karten, ohne TanStack, Auswahl oder Toolbar.

    Solltest du direkt auf TanStack bauen oder eine Komponente nutzen?

    Bau direkt, wenn die Tabelle ein Einzelstück ist oder dein Design System die Menüs und Sheets schon hat. Nimm eine Komponente, wenn du dieselbe Admin-Tabelle auf zwanzig Screens brauchst. Die Optionen, die wir in Betracht ziehen würden, Stand Oktober 2026:

    OptionLizenz und PreisServer-ModusBulk ActionsPinningSmartphone-Layout
    TanStack Table v9 (opens in a new tab) alleinMIT, kostenlosNur StateNur StateNur StateSelbst bauen
    Guide zur shadcn Data Table (opens in a new tab)MIT, kostenlosVerweis auf die TanStack-DokuZahl der ausgewählten ZeilenNeinNein
    tablecn (opens in a new tab)MIT, kostenlosJa, mit Drizzle und PostgreSQLAktionsleiste bei AuswahlNicht aufgeführtNicht aufgeführt
    Wingo UI Data TablePro: $8 im Monat, $80 im Jahr oder $150 einmalig (Lifetime)Ja (mode="server")Ja, schwebend auf dem SmartphoneJa, plus Header-MenüKarten unter 768px

    Willst du erweiterte Filter im Stil von Notion über einer Postgres-Tabelle, ist tablecn der bessere kostenlose Startpunkt. Brauchst du nur Sortierung, einen Textfilter und Client-Pagination, reicht der shadcn-Guide. Die Data Table gehört, zusammen mit Table, Pagination und Toolbar, auf denen sie aufbaut, zu Wingo UI Pro.

    Was solltest du als Nächstes bauen?

    Öffne den Playground in der Doku zur Data Table, stell mode auf server und schau zu, wie sich das Query-Objekt beim Sortieren und Filtern ändert. Installier sie dann mit npx wingo-ui@latest add data-table nach einem einmaligen CLI-Login. Weitere Tabellen der Bibliothek, von der Mitgliedertabelle bis zum editierbaren Grid, findest du unter Tabellen, und mehr Komponenten-Tutorials stehen in der Kategorie Komponenten-Guides, angefangen beim Guide zu React-Formularkomponenten für die Filterformulare rund um deine Tabellen.

    Komponenten in diesem Artikel

    • Data Table

      A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

      Pro
    • Table

      A React table component: semantic table parts plus a props API for small read-only tables that sort, total and turn into cards on phones.

      Pro
    • Pagination

      A React pagination for tables and lists: numbered pages with ellipses, previous / next, page size, jump to page and load more.

      Pro
    • Toolbar

      A React toolbar for lists and tables: search, filter chips, applied filters, sort, a list or grid switch, the count, actions and bulk mode.

      Pro

    Häufige Fragen

    Was ersetzt useReactTable in TanStack Table v9?

    useTable, mit einer Pflichtoption features, die du mit tableFeatures() baust. Row Models wie Sortierung und Pagination liegen als Factories in diesem features-Objekt (sortedRowModel: createSortedRowModel()), und useLegacyTable aus @tanstack/react-table/legacy hält die v8-API als veraltete Migrationshilfe am Leben.

    Wie funktioniert Server-Side Pagination in TanStack Table v9?

    Setz manualPagination: true, gib die Gesamtzahl als rowCount mit, steuere den pagination-State selbst und lade die Seite, die deine API dafür liefert. Sortiert und filtert auch der Server, kommen manualSorting und manualFiltering dazu, und die passenden Row-Model-Factories lässt du in tableFeatures weg, weil der Browser sie nie ausführt.

    Wie pinne ich Spalten in TanStack Table v9?

    Registrier columnPinningFeature und setz columnPinning auf { start: [...], end: [...] }; v9 hat left und right ohne Aliase in start und end umbenannt. Registrier auch columnSizingFeature, mach die gepinnten Zellen position: sticky mit column.getStart('start') oder column.getAfter('end') als Offset und gib ihnen eine Breite und einen Hintergrund, damit die Offsets stimmen und keine Zeilen durchscheinen.

    Funktioniert die shadcn Data Table auf dem Smartphone?

    Stand Oktober 2026 deckt der Data-Table-Guide von shadcn Sortierung, Filter, Spaltensichtbarkeit, Client-Pagination und Zeilenauswahl auf TanStack Table v9 ab, aber kein Smartphone-Layout: Die Tabelle behält ihre Spalten und scrollt seitwärts. Eine Karte pro Zeile unter 768px baust du selbst oder holst sie dir aus einer Komponente, die sie mitbringt.

    Ist die Data Table von Wingo UI kostenlos?

    Nein. Die Data Table gehört zu Wingo UI Pro, ebenso Table, Pagination und Toolbar, auf denen sie aufbaut. Checkbox, Dropdown Menu, Popover und die anderen Basiskomponenten, die sie nutzt, sind für sich allein kostenlos.

    • TanStack Table
    • Datentabelle
    • React
    • shadcn/ui
    • Mobile UI
    • Next.js

    Teilen

    SR

    Über den Autor

    Serban Rusu

    Gründer von Wingo UI

    Serban Rusu hat Wingo UI gegründet. Er baut die Komponenten, die CLI und den MCP-Server und schreibt über React-UIs für Smartphones und KI-Agents.

    Mehr von Serban
    Weiterlesen

    Ähnliche Artikel

    Alle Artikel
    Komponenten-Guides

    React Virtualized Table: 100.000 Zeilen mit TanStack Virtual

    Bau eine React Virtualized Table mit TanStack Virtual, die 100.000 Zeilen scrollt, den Header sticky hält und Fokus und Auswahl beim Unmounten nicht verliert.

    SRSerban Rusu·16. Okt. 2026·13 Min. Lesezeit
    Mobile UI mit React

    Responsive Tabelle: Zeilen als Karten auf dem Smartphone

    Responsive Tabelle fürs Smartphone: wann Zeilen unter 768px zu Karten werden, welche Spalten bleiben und wie Sortieren und Sammelaktionen ohne Kopfzeile gehen.

    SRSerban Rusu·9. Okt. 2026·11 Min. Lesezeit
    Komponenten-Guides

    React Empty State: Erste Nutzung, keine Treffer, Fehler

    Bau eine React Empty State Komponente für erste Nutzung, keine Treffer und Fehler, mit Texten, die sagen, was passiert ist, und einer Aktion, die weiterhilft.

    SRSerban Rusu·19. Okt. 2026·9 Min. Lesezeit
    Newsletter

    Neue Artikel per E-Mail

    Neue Guides, Tutorials und Vergleiche aus dem Wingo UI Blog, sobald sie erscheinen. Die meisten Artikel sind auf Englisch.

    Kein Spam. Du kannst dich jederzeit abmelden.

    WingoUI

    Animierte, konfigurierbare, mobile-first React-Komponenten und Blöcke. Kopier den Quellcode, pass ihn an und lass deinen Coding-Agenten damit bauen.

    Produkt

    • Komponenten
    • Vorlagen
    • Preise
    • KI-Agenten
    • Theme-Generator

    Ressourcen

    • Doku
    • Blog
    • Changelog
    • Changelog-RSS
    • llms.txt

    Rechtliches

    • AGB
    • Datenschutz
    • Rückerstattung
    • Lizenz
    • Impressum

    Komponenten-Kategorien

    • Buttons & Actions
    • Inputs
    • Forms
    • Navigation
    • Overlays
    • Feedback
    • Data Display
    • Tables & Lists
    • Charts & Stats
    • Layout
    • Media
    • AI Kit
    • Text & Effects
    • Mobile
    • Commerce
    • Marketing Sections
    • Blocks
    • Hooks & Utilities

    © 2026 Wingo UI

    Wingo UI