# TanStack Table v9: Data Table mit Filtern und Bulk Actions

> Bau eine Admin-Datentabelle auf TanStack Table v9: tableFeatures, Spaltenfilter, Bulk Actions, Pinning mit start/end, Server-Pagination, Smartphone-Karten.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 9. Okt. 2026
- Kategorie: [Komponenten-Guides](https://wingo-ui.com/de/blog/category/components)
- Lesezeit: 12 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/tanstack-table-v9-data-table
- Englische Fassung: https://wingo-ui.com/blog/tanstack-table-v9-data-table

## 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.

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](https://wingo-ui.com/components/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](https://tanstack.com/table/latest/docs/framework/react/guide/migrating), Stand Oktober 2026:

| v8 | v9 |
| --- | --- |
| `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`, `sortingFns` | `sortFn`, `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](https://tanstack.com/blog/announcing-tanstack-table-v9) (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](https://ui.shadcn.com/docs/components/data-table), 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](https://wingo-ui.com/components/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.

> Live-Demo (Data Table): 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. Probier sie unter [Data Table](https://wingo-ui.com/components/data-table) aus und installier sie mit `npx wingo-ui@latest add 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:

| `filter` | Gedacht für | Wert |
| --- | --- | --- |
| `text` | Namen, E-Mails, IDs | `"cafe"` |
| `select` | ein Betreuer, ein Tarif | `"Emma Carter"` |
| `multi-select` | Status, Städte, Tags | `["Active", "Overdue"]` |
| `number-range` | Salden, Mengen | `[1000, null]` (jedes Ende darf null sein) |
| `date-range` | Rechnungsdaten, 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/de/blog/nextjs-admin-dashboard-tutorial), wie das mit `router.replace` und einer Server Component geht.

> Live-Demo (Data Table): 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. Probier sie unter [Data Table](https://wingo-ui.com/components/data-table) aus und installier sie mit `npx wingo-ui@latest add 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](https://wingo-ui.com/de/blog/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](https://wingo-ui.com/components/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](https://wingo-ui.com/de/blog/mobile-first-react-components). 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](https://wingo-ui.com/de/blog/responsive-table-mobile-cards).

Für kleine Tabellen zum Lesen, etwa Rechnungspositionen oder eine Preisliste, macht die einfache [Table](https://wingo-ui.com/components/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:

| Option | Lizenz und Preis | Server-Modus | Bulk Actions | Pinning | Smartphone-Layout |
| --- | --- | --- | --- | --- | --- |
| [TanStack Table v9](https://tanstack.com/table/latest) allein | MIT, kostenlos | Nur State | Nur State | Nur State | Selbst bauen |
| [Guide zur shadcn Data Table](https://ui.shadcn.com/docs/components/data-table) | MIT, kostenlos | Verweis auf die TanStack-Doku | Zahl der ausgewählten Zeilen | Nein | Nein |
| [tablecn](https://github.com/sadmann7/tablecn) | MIT, kostenlos | Ja, mit Drizzle und PostgreSQL | Aktionsleiste bei Auswahl | Nicht aufgeführt | Nicht aufgeführt |
| Wingo UI Data Table | Pro: $8 im Monat, $80 im Jahr oder $150 einmalig (Lifetime) | Ja (`mode="server"`) | Ja, schwebend auf dem Smartphone | Ja, 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](https://wingo-ui.com/de/pricing).

## Was solltest du als Nächstes bauen?

Öffne den Playground in der [Doku zur Data Table](https://wingo-ui.com/components/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](https://wingo-ui.com/docs/cli). Weitere Tabellen der Bibliothek, von der Mitgliedertabelle bis zum editierbaren Grid, findest du unter [Tabellen](https://wingo-ui.com/components/category/tables), und mehr Komponenten-Tutorials stehen in der Kategorie [Komponenten-Guides](https://wingo-ui.com/de/blog/category/components), angefangen beim [Guide zu React-Formularkomponenten](https://wingo-ui.com/de/blog/react-form-components-guide) für die Filterformulare rund um deine Tabellen.

## Komponenten in diesem Artikel

- [Data Table](https://wingo-ui.com/components/data-table) (Pro): A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Installation: `npx wingo-ui@latest add data-table`
- [Table](https://wingo-ui.com/components/table) (Pro): 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. Installation: `npx wingo-ui@latest add table`
- [Pagination](https://wingo-ui.com/components/pagination) (Pro): A React pagination for tables and lists: numbered pages with ellipses, previous / next, page size, jump to page and load more. Installation: `npx wingo-ui@latest add pagination`
- [Toolbar](https://wingo-ui.com/components/toolbar) (Pro): A React toolbar for lists and tables: search, filter chips, applied filters, sort, a list or grid switch, the count, actions and bulk mode. Installation: `npx wingo-ui@latest add toolbar`

## 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.

---

Quelle: https://wingo-ui.com/de/blog/tanstack-table-v9-data-table
