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

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

## Kurz gesagt

Eine React Virtualized Table rendert nur die Zeilen im Viewport plus einen Overscan-Puffer: useVirtualizer von TanStack Virtual macht aus der Scrollposition Zeilenindizes, und du renderst diese Zeilen aus dem sortierten Row Model von TanStack Table. Spacer-Zeilen darüber und darunter füllen die fehlende Höhe, deshalb funktionieren native Spaltenbreiten und ein Header mit position: sticky weiter. Verfolge Fokus und Auswahl über die Zeilen-ID statt über den DOM-Knoten, und lass die fokussierte Zeile gerendert, während sie aus dem Bild scrollt.

Eine Tabelle mit 100.000 Zeilen friert den Tab ein, wenn du jedes `<tr>` renderst. Eine React Virtualized Table löst das: Sie rendert nur die Zeilen, die man sieht, plus ein paar über jeden Rand hinaus, und tauscht sie beim Scrollen aus. Schwierig ist alles andere. Der Sticky Header bleibt plötzlich nicht mehr oben, die Pfeiltasten verlieren den Fokus, wenn die fokussierte Zeile verschwindet, und die Auswahl per Shift-Klick kennt nur die Zeilen im DOM. Dieses Tutorial baut eine TanStack Virtual Table auf TanStack Table v9, die alle drei Probleme löst. Am Ende siehst du die [Data Table](https://wingo-ui.com/components/data-table) von Wingo UI, unsere Komponente, die dasselbe hinter einem einzigen `virtualize`-Prop erledigt.

## Warum friert eine Tabelle mit 100.000 Zeilen React ein?

Sie friert ein, weil die Kosten im DOM mit jeder gerenderten Zeile wachsen. Eine Tabelle mit 100.000 Bestellungen und sechs Spalten besteht aus 100.000 `<tr>`- und 600.000 `<td>`-Elementen. React muss sie erzeugen, der Browser muss ihr Layout berechnen, und jede Sortierung oder Änderung der Auswahl fasst sie wieder an.

Mit Virtualisierung kannst du 100.000 Zeilen in React rendern, ohne für alle zu bezahlen. In einer Scrollbox mit 520px Höhe und 40px hohen Zeilen sind unter dem Header etwa 12 Zeilen sichtbar. Mit einem Overscan von 10 Zeilen auf jeder Seite hält das DOM rund 32 Zeilen, und diese Zahl bleibt gleich, ob die Daten 1.000 oder 100.000 Zeilen haben. Die Scrollleiste sieht trotzdem richtig aus, weil Spacer-Elemente die Höhe der nicht gerenderten Zeilen einnehmen.

Was Virtualisierung nicht ändert: Die Daten liegen weiter im Speicher, und das Sortieren oder Filtern von 100.000 Objekten läuft bei jeder Änderung in JavaScript. Der [Guide zur Virtualisierung von TanStack Table](https://tanstack.com/table/latest/docs/framework/react/guide/virtualization) stellt klar, dass Virtualisierung Pagination, Filtern und Sortieren auf dem Server nicht ersetzt. Ist der Datensatz zu groß, um ihn in den Browser zu laden, nimm serverseitige Operationen oder Infinite Scroll.

## Wie entscheidet TanStack Virtual, welche Zeilen es rendert?

TanStack Virtual vergleicht den Scroll-Offset mit dem Start- und End-Offset jeder Zeile und gibt nur die Indizes im Viewport zurück, plus den Overscan. `useVirtualizer` aus `@tanstack/react-virtual` bekommt einen `count`, eine Funktion `getScrollElement` und eine `estimateSize`. Bei jedem Scrollen liefert es `getVirtualItems()`, also die sichtbaren Indizes mit je einem `start`- und `end`-Offset in Pixeln, und `getTotalSize()`, die Höhe aller Zeilen zusammen. Deine Daten fasst es nie an. Jeden virtuellen Index ordnest du selbst `rows[index]` aus `table.getRowModel().rows` zu, und diese Liste ist schon sortiert und gefiltert.

Es gibt zwei Wege, diese Zeilen in ein `<table>`-Element zu setzen, und die Wahl entscheidet, wie viel von der Tabelle du behältst:

| Ansatz | Wie die Zeilen platziert werden | Was du behältst | Was kaputtgeht |
| --- | --- | --- | --- |
| Absolute Zeilen (das [Beispiel für virtualisierte Zeilen von TanStack Table](https://github.com/TanStack/table/tree/main/examples/react/virtualized-rows)) | `tbody` bekommt die Gesamthöhe, jedes `tr` ist `position: absolute` mit `translateY(start)` | Unterschiedliche Zeilenhöhen über `measureElement` | Das native Tabellenlayout: Die Beispiele stellen die Tabelle auf `display: grid` und die Zeilen auf `display: flex` um, also setzt du jede Spaltenbreite selbst |
| Spacer-Zeilen | Ein leeres `<tr>` über den gerenderten Zeilen und eins darunter, jeweils so hoch wie die Zeilen, für die es steht | Native Spaltenbreiten, Header mit `position: sticky`, `colSpan`, normale Rahmen | Funktioniert am besten mit fester Zeilenhöhe |

Baust du eine virtuelle Tabelle in React mit fester Zeilenhöhe, sind Spacer-Zeilen der einfachere Weg: Jedes Element behält seinen nativen `display`-Wert einer Tabelle, also schreibst du keine Spaltenbreite von Hand. Diesen Ansatz nutzen wir unten. Wechsle zu absoluten Zeilen, wenn die Zeilenhöhen variieren.

## Wie baue ich eine virtualisierte Tabelle mit TanStack Table v9?

Bau die Tabelle mit `useTable`, gib ihre Zeilenzahl an `useVirtualizer` weiter und render nur die virtuellen Items, mit einer Spacer-Zeile darüber und einer darunter. Installier beide Pakete:

```bash
npm install @tanstack/react-table @tanstack/react-virtual
```

Die Komponente unten sortiert per Klick auf den Header, wählt per Checkbox, Shift-Klick und Strg/Cmd + A aus, hat einen Sticky Header und bewegt den Fokus mit den Pfeiltasten, PageUp / PageDown und Home / End. Jede Zeile und der Header sind genau 40px hoch, deshalb muss der Virtualizer nichts messen. Die Klassennamen nutzen die Design-Tokens von Wingo UI (`bg-surface`, `shadow-raised`); ersetz sie durch deine eigenen.

```tsx
"use client"

import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual"
import {
  createColumnHelper,
  createSortedRowModel,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_text,
  tableFeatures,
  useTable,
} from "@tanstack/react-table"

type Order = { id: string; customer: string; city: string; status: string; total: number }

const ROW = 40 // jede Zeile und der Header sind genau 40px hoch (h-10)
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { text: sortFn_text, alphanumeric: sortFn_alphanumeric },
  rowSelectionFeature,
})

const helper = createColumnHelper<typeof features, Order>()
const columns = helper.columns([
  helper.accessor("id", { header: "Bestellung" }),
  helper.accessor("customer", { header: "Kunde" }),
  helper.accessor("city", { header: "Stadt" }),
  helper.accessor("status", { header: "Status" }),
  helper.accessor("total", { header: "Summe", cell: (info) => usd.format(info.getValue()) }),
])

export function OrdersTable({ data }: { data: Order[] }) {
  const table = useTable({ features, columns, data, getRowId: (row) => row.id })
  const rows = table.getRowModel().rows
  const scrollRef = useRef<HTMLDivElement>(null)

  // die aktive Zeile hängt an ihrer ID, also schiebt eine Sortierung den Fokus nie auf eine andere Bestellung
  const [activeId, setActiveId] = useState<string | null>(null)
  const activeIndex = useMemo(() => {
    const i = activeId === null ? -1 : rows.findIndex((r) => r.id === activeId)
    return i === -1 ? 0 : i
  }, [rows, activeId])

  // die aktive Zeile bleibt gerendert, auch wenn sie aus dem Bild scrollt
  const rangeExtractor = useCallback(
    (range: Range) => {
      const indexes = new Set(defaultRangeExtractor(range))
      if (activeIndex < range.count) indexes.add(activeIndex)
      return [...indexes].sort((a, b) => a - b)
    },
    [activeIndex],
  )

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => ROW,
    getItemKey: (index) => rows[index].id,
    overscan: 10,
    scrollMargin: ROW, // der Body beginnt unter dem Header
    scrollPaddingStart: ROW, // scrollToIndex parkt nie eine Zeile unter dem Sticky Header
    rangeExtractor,
    initialRect: { width: 0, height: 520 }, // der Server rendert den ersten Bildschirm voller Zeilen
  })

  const pendingFocus = useRef(false)
  useEffect(() => {
    if (!pendingFocus.current) return
    pendingFocus.current = false
    scrollRef.current?.querySelector<HTMLElement>(`tr[data-index="${activeIndex}"]`)?.focus({ preventScroll: true })
  })

  const move = (to: number) => {
    const i = Math.max(0, Math.min(rows.length - 1, to))
    setActiveId(rows[i].id)
    pendingFocus.current = true
    virtualizer.scrollToIndex(i, { align: "auto" })
  }

  const onKeyDown = (event: KeyboardEvent<HTMLTableRowElement>, index: number) => {
    const page = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 400) / ROW) - 2)
    const row = rows[index]
    const keys: Record<string, () => void> = {
      ArrowDown: () => move(index + 1),
      ArrowUp: () => move(index - 1),
      PageDown: () => move(index + page),
      PageUp: () => move(index - page),
      Home: () => move(0),
      End: () => move(rows.length - 1),
      " ": () => row.toggleSelected(),
    }
    if ((event.metaKey || event.ctrlKey) && event.key === "a") {
      event.preventDefault()
      table.toggleAllRowsSelected(true)
      return
    }
    const action = keys[event.key]
    if (!action) return
    event.preventDefault()
    action()
  }

  // gerenderte Zeilen mit Spacer-Zeilen in den Lücken, damit das <table> sein eigenes Layout behält
  const items = virtualizer.getVirtualItems()
  const colSpan = columns.length + 1
  const body: ReactNode[] = []
  let cursor = ROW
  for (const item of items) {
    if (item.start > cursor) body.push(<Spacer key={`gap-${item.index}`} height={item.start - cursor} colSpan={colSpan} />)
    const row = rows[item.index]
    body.push(
      <tr
        key={row.id}
        data-index={item.index}
        aria-rowindex={item.index + 2}
        tabIndex={item.index === activeIndex ? 0 : -1}
        onFocus={() => setActiveId(row.id)}
        onKeyDown={(event) => onKeyDown(event, item.index)}
        className="h-10 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring data-[selected]:bg-foreground/7 [&>td]:border-b [&>td]:border-border"
        data-selected={row.getIsSelected() || undefined}
      >
        <td className="w-10 px-3">
          <input
            type="checkbox"
            tabIndex={-1}
            aria-label={`Bestellung ${row.original.id} auswählen`}
            checked={row.getIsSelected()}
            onChange={row.getToggleSelectedHandler()}
          />
        </td>
        {row.getAllCells().map((cell) => (
          <td key={cell.id} className="whitespace-nowrap px-3 text-sm">
            <table.FlexRender cell={cell} />
          </td>
        ))}
      </tr>,
    )
    cursor = item.end
  }
  const end = virtualizer.getTotalSize() + ROW
  if (end > cursor) body.push(<Spacer key="end" height={end - cursor} colSpan={colSpan} />)

  return (
    <div ref={scrollRef} className="h-[520px] overflow-auto overscroll-contain rounded-2xl bg-surface shadow-raised">
      <table aria-rowcount={rows.length + 1} className="w-full border-separate border-spacing-0">
        <caption className="sr-only">Bestellungen</caption>
        <thead>
          {table.getHeaderGroups().map((group) => (
            <tr key={group.id} aria-rowindex={1}>
              <th className="sticky top-0 z-10 h-10 w-10 bg-surface px-3">
                <input
                  type="checkbox"
                  aria-label="Alle Bestellungen auswählen"
                  checked={table.getIsAllRowsSelected()}
                  ref={(el) => {
                    if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
                  }}
                  onChange={table.getToggleAllRowsSelectedHandler()}
                />
              </th>
              {group.headers.map((header) => {
                const sorted = header.column.getIsSorted()
                return (
                  <th
                    key={header.id}
                    aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
                    className="sticky top-0 z-10 h-10 bg-surface px-3 text-left text-sm font-medium text-muted-foreground"
                  >
                    <button type="button" onClick={header.column.getToggleSortingHandler()}>
                      <table.FlexRender header={header} />
                      {sorted === "asc" ? " ↑" : sorted === "desc" ? " ↓" : null}
                    </button>
                  </th>
                )
              })}
            </tr>
          ))}
        </thead>
        <tbody>{body}</tbody>
      </table>
    </div>
  )
}

function Spacer({ height, colSpan }: { height: number; colSpan: number }) {
  return (
    <tr aria-hidden style={{ height }}>
      <td colSpan={colSpan} className="p-0" />
    </tr>
  )
}
```

Die meiste Arbeit steckt in ein paar wenigen Zeilen.

### Spacer-Zeilen statt Transforms

Die Schleife geht die virtuellen Items der Reihe nach durch und setzt ein Spacer-`<tr>` überall dort ein, wo das nächste Item tiefer beginnt, als das letzte geendet hat. Das deckt die Lücke über der ersten gerenderten Zeile ab, die Lücke unter der letzten und jedes Loch dazwischen (warum es ein Loch geben kann, siehst du im nächsten Abschnitt). Weil die Zeilen im normalen Fluss bleiben, bemisst der Browser die Spalten nach ihrem Inhalt, wie bei jeder anderen Tabelle. Eine Falle gibt es: Die Tabelle nutzt `border-separate`, damit der Sticky Header sein Aussehen behält, und in diesem Rahmenmodell wird ein Rahmen auf einem `<tr>` nicht gezeichnet. Die Zeilenrahmen sitzen deshalb auf den Zellen (`[&>td]:border-b`).

### Ein Sticky Header, der an seinem Platz bleibt

Die Header-Zellen sind `position: sticky; top: 0` innerhalb des Elements, das scrollt. Sie haben einen Hintergrund, damit keine Zeilen durchscheinen, und einen `z-index` über dem Body. Über zwei Optionen erfährt der Virtualizer vom Header: `scrollMargin: ROW` sagt, dass die erste Zeile 40px tiefer im Scroll-Inhalt beginnt, und `scrollPaddingStart: ROW` lässt `scrollToIndex` die Zielzeile direkt unter dem Header anhalten, statt sie dahinter zu verstecken. Ist dein Header höher, setz beide auf seine Höhe.

### Der erste Bildschirm auf dem Server

Auf dem Server gibt es kein Scroll-Element. Der Virtualizer würde null Zeilen rendern, und die Seite käme leer an. `initialRect` gibt ihm eine Viewport-Höhe, mit der er den ersten Bildschirm plant. So enthält das HTML vom Server schon die ersten Zeilen, und beim Hydrieren springt nichts.

## Wie bleibt die Tastaturnavigation intakt, wenn Zeilen verschwinden?

Verfolge die fokussierte Zeile über ihre ID, scroll zu ihr, bevor du sie fokussierst, und lass sie gerendert, solange sie außerhalb des Bildschirms ist. Die Zeilen nutzen einen Roving Tabindex: Die aktive Zeile hat `tabIndex={0}`, jede andere `-1`. Die Tabelle ist also ein einziger Tab-Stopp, und die Pfeiltasten bewegen sich darin. Ärger gibt es, wenn die nächste Zeile noch nicht gerendert ist. Drückst du ArrowDown auf der letzten sichtbaren Zeile, muss die Tabelle erst scrollen und dann fokussieren.

Die Funktion `move` erledigt beides. Sie speichert die ID der Zielzeile als aktiv, ruft `virtualizer.scrollToIndex(i, { align: "auto" })` auf und merkt sich einen ausstehenden Fokus. Nach dem Render sucht der Effect `tr[data-index]` für den aktiven Index und fokussiert die Zeile mit `preventScroll: true`. So streitet der Browser nicht mit dem Virtualizer um die Scrollposition.

Der `rangeExtractor` löst die zweite Hälfte des Problems. Ohne ihn entfernt das Scrollen mit dem Mausrad die fokussierte Zeile aus dem DOM, der Fokus fällt auf `<body>` zurück, und die nächste Pfeiltaste scrollt die Seite statt der Tabelle. Der Extractor fügt den aktiven Index zu jedem sichtbaren Bereich hinzu. Diese eine Zeile bleibt also im DOM, egal wo sie gerade steht, mit einem Spacer auf jeder Seite. Daher kommt das Loch zwischen gerenderten Zeilen.

Für Screenreader zählen zwei weitere Details. Setz `aria-rowcount` auf der Tabelle auf die volle Zeilenzahl plus den Header und `aria-rowindex` auf jede gerenderte Zeile. Dann bekommen assistive Technologien die echte Position (Zeile 48.213 von 100.001) statt der Position unter den 32 Zeilen, die gerade existieren. Teste trotzdem mit VoiceOver und NVDA: Laut [WebKit-Bug 236790](https://bugs.webkit.org/show_bug.cgi?id=236790), gemeldet im Februar 2022 und Stand Oktober 2026 noch offen, sagt VoiceOver den Wert von `aria-rowindex` nicht an, wenn der Fokus zwischen den Zeilen eines ARIA-Datengrids wechselt.

## Wie funktioniert die Auswahl, wenn die meisten Zeilen nicht im DOM stehen?

Die Auswahl liegt im State von TanStack Table, mit `getRowId` als Schlüssel, nie im DOM. Deshalb funktioniert sie auch bei 100.000 Zeilen:

- Eine Checkbox ruft `row.getToggleSelectedHandler()` auf. In v9 wählt dieser Handler bei Shift-Klick standardmäßig einen Bereich aus, und der Bereich läuft über das Row Model. Shift-Klick auf Zeile 10 und dann auf Zeile 90.000 wählt also alles dazwischen aus, gerendert oder nicht.
- Strg/Cmd + A ruft `table.toggleAllRowsSelected(true)` auf, und die Header-Checkbox liest `getIsAllRowsSelected()` und `getIsSomeRowsSelected()`. In v9 ist die zweite auch dann `true`, wenn jede Zeile ausgewählt ist. Daher das `&& !getIsAllRowsSelected()` für den Zwischenzustand.
- Auch die aktive Zeile hängt an ihrer ID. Eine Sortierung gibt den Fokus also nicht an die Bestellung weiter, die zufällig auf dem alten Index landet.

Eine Leiste für Sammelaktionen sollte `table.getSelectedRowModel().rows` lesen, nie die angehakten Inputs im DOM.

## Wie baue ich Infinite Scroll in eine virtualisierte Tabelle ein?

Halte die geladenen Seiten in einem Array und lass den `count` des Virtualizers damit wachsen. Prüf nach jedem Render das letzte virtuelle Item: Ist sein Index nur noch wenige Zeilen von `rows.length` entfernt, gibt es weitere Seiten und läuft keine Anfrage, lädst du die nächste Seite und hängst sie an. Das [„Virtualized Infinite Scrolling“-Beispiel von TanStack Table](https://github.com/TanStack/table/tree/main/examples/react/virtualized-infinite-scrolling) macht dasselbe mit `useInfiniteQuery` aus TanStack Query und lädt die nächste Seite, sobald die Scrollposition weniger als 500px vom Ende entfernt ist. Zwei Warnungen: Sortieren und Filtern sehen nur die geladenen Zeilen, also sollte ein großer Datensatz auf dem Server sortieren. Und eine Ladezeile am Ende sorgt dafür, dass die Scrollleiste stimmt, während die Seite lädt.

## Was geht bei 100.000 Zeilen immer noch kaputt?

Vier Dinge gehen auch nach der Virtualisierung noch kaputt: die Suche im Browser, das Höhenlimit des Browsers, Sortier-Animationen und unterschiedliche Zeilenhöhen.

- **Suche im Browser.** Strg+F sieht keine Zeilen, die nicht gerendert sind. Liefere ein Suchfeld mit, das das Row Model filtert.
- **Das Höhenlimit.** Browser begrenzen, wie hoch ein Element sein darf. [Kohiis Artikel über virtuelles Scrollen jenseits des Browser-Limits](https://dev.to/kohii/how-to-implement-virtual-scrolling-beyond-the-browsers-limit-16ol) hat 16.777.216px in Chromium und 33.554.428px in Safari gemessen. 100.000 Zeilen mit 40px ergeben 4 Millionen Pixel und passen. Ab etwa 419.000 Zeilen mit 40px schneidet Chromium den Scrollbereich ab, also paginierst du dann besser auf dem Server.
- **Sortier-Animationen.** Eine FLIP-Animation braucht für jede Zeile die alte und die neue Position, und eine Zeile, die gerade erst gemountet wurde, hat keine alte. Schalte Zeilen-Animationen ab, solange die Tabelle virtualisiert ist. Die Data Table animiert eine Sortierung nur, wenn die Virtualisierung aus ist und höchstens 50 Zeilen gerendert werden.
- **Unterschiedliche Zeilenhöhen.** Umbrechender Text oder aufklappbare Zeilen brauchen `measureElement` und eine möglichst genaue `estimateSize`, und das absolute Layout kommt damit besser klar als Spacer-Zeilen.

## Gibt es eine React-Datentabelle mit eingebauter Virtualisierung?

Ja: Die [Data Table](https://wingo-ui.com/components/data-table) von Wingo UI nutzt denselben Ansatz mit Spacer-Zeilen auf TanStack Table v9 und TanStack Virtual. Setz `virtualize` (oder lass es auf `"auto"`, das sich bei mehr als 200 gerenderten Zeilen einschaltet). Dann rendert sie die sichtbaren Zeilen mit einem Overscan von 10, setzt `aria-rowcount` und hält den Header sticky. Mit `maxHeight` scrollt sie innerhalb dieser Höhe. Ohne `maxHeight` virtualisiert sie über den nächsten scrollenden Vorfahren (oder die Seite), und der Header klebt unter `--table-sticky-top`. Bevor der Scroll-Container bekannt ist, also auf dem Server und beim ersten Paint, rendert sie die ersten 30 Zeilen ganz normal.

Die Tastatur folgt derselben Regel wie beim `OrdersTable`: erst scrollen, dann fokussieren. Die Zeilen sind ein einziger Tab-Stopp. Up / Down, Home / End und PageUp / PageDown scrollen die Zielzeile ins Bild, bevor sie den Fokus bekommt. Leertaste wählt aus, Shift + Pfeiltasten erweitern die Auswahl, Strg/Cmd + A wählt die Seite aus (oder mit `selectAllScope="all"` jede passende Zeile), und Escape hebt die Auswahl auf. Die Auswahl hängt an den Zeilen-Keys und übersteht Sortieren, Filtern und Blättern. Ein Unterschied zum Code oben: Die Data Table hält die fokussierte Zeile nicht fest. Entfernt ein Scrollen mit dem Mausrad sie aus dem DOM, ist der Fokus weg, bis du wieder auf eine Zeile klickst. Die Virtual List weiter unten hält sie fest. Auf dem Smartphone wird jede Zeile zur Karte, und die Karten werden über das Scrollen der Seite virtualisiert, mit gemessenen Höhen.

```bash
npx wingo-ui@latest add data-table
```

```tsx
import { DataTable, createColumns } from "@/components/ui/data-table"

type Order = { id: string; customer: string; city: string; status: string; total: number; placedAt: string }

const col = createColumns<Order>()
const columns = col([
  col.accessor("id", { header: "Bestellung" }),
  col.accessor("customer", { header: "Kunde", filter: "text" }),
  col.accessor("city", { header: "Stadt", filter: "multi-select" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Paid: { tone: "success" }, Pending: { tone: "warning" }, Refunded: { tone: "neutral" } },
    filter: "multi-select",
  }),
  col.accessor("total", { header: "Summe", type: "currency", footer: "sum" }),
  col.accessor("placedAt", { header: "Bestellt", type: "date" }),
])

export function Orders({ orders }: { orders: Order[] }) {
  return (
    <DataTable
      data={orders}
      columns={columns}
      caption="Bestellungen"
      locale="en-US"
      pagination="none"
      virtualize
      maxHeight={560}
      defaultDensity="compact"
      selectionMode="multiple"
      selectAllScope="all"
      searchPlaceholder="Bestellungen suchen"
    />
  )
}
```

Die Demo unten lädt 1.240 Kunden, ohne Pagination, mit Virtualisierung und einer Scrollbox von 520px.

> Live-Demo (Data Table): Scroll durch die Kunden, klick dann auf eine Zeile und halte ArrowDown oder drück End: Der Fokus folgt in Zeilen, die gerade eben noch nicht gerendert waren. Shift-Klick auf zwei weit entfernte Checkboxen wählt den Bereich dazwischen aus. Probier sie unter [Data Table](https://wingo-ui.com/components/data-table) aus und installier sie mit `npx wingo-ui@latest add data-table`.

Für Infinite Scroll setzt du `pagination="infinite"` und gibst `onLoadMore`, `hasMore` und `loadingMore` mit: Die Tabelle fragt nach der nächsten Seite, sobald das Ende ins Bild kommt, und die Virtualisierung gilt weiter für die geladenen Zeilen. Mit `mode="server"` gehen Sortieren, Suche und Filter stattdessen über `onQueryChange` an deine API.

## Wann nehme ich eine virtuelle Liste statt einer Tabelle?

Nimm eine Liste statt einer Tabelle, wenn die Zeilen keine Spalten haben, die man vergleicht. Eine Kontaktliste, ein Feed oder eine Auswahl liest sich besser als Liste. Die [Virtual List](https://wingo-ui.com/components/virtual-list) virtualisiert so etwas, mit Sticky-Gruppenüberschriften, Infinite Loading, wiederhergestellter Scrollposition und derselben Regel, die fokussierte Zeile gerendert zu lassen, während sie aus dem Bild scrollt.

> Live-Demo (Virtual List): Wisch durch 5.000 Kunden: Der Buchstaben-Header heftet sich oben an, und der nächste schiebt ihn hoch. Klick auf eine Zeile und nutze dann die Pfeiltasten, der Fokus wandert in Zeilen, die noch nicht gerendert waren. Probier sie unter [Virtual List](https://wingo-ui.com/components/virtual-list) aus und installier sie mit `npx wingo-ui@latest add virtual-list`.

Streamender Text ist ein weiterer Fall. Build-Logs, Job-Ausgaben und Audit-Trails brauchen eher Tail-Follow, Level-Filter und Suche als sortierbare Spalten. Der [Log Viewer](https://wingo-ui.com/components/log-viewer) hält standardmäßig bis zu 100.000 Zeilen (`maxLines`) in einem Ringpuffer mit virtualisiertem Body. Der [Guide zum React Log Viewer](https://wingo-ui.com/de/blog/react-log-viewer) erklärt ihn. Brauchst du nur gestyltes Tabellen-Markup ohne Sortieren oder Virtualisierung, nimm die [Table](https://wingo-ui.com/components/table)-Primitives, aus denen die Data Table gebaut ist.

Mehr zur Tabelle selbst: Das [Tutorial zur Data Table mit TanStack Table v9](https://wingo-ui.com/de/blog/tanstack-table-v9-data-table) behandelt Filter, Sammelaktionen und Pagination auf dem Server, und der Artikel über [responsive Tabellen-Layouts für Mobile](https://wingo-ui.com/de/blog/responsive-table-mobile-cards) erklärt, warum Zeilen unter 768px zu Karten werden. Alle Komponenten-Guides findest du in der [Kategorie Komponenten-Guides](https://wingo-ui.com/de/blog/category/components).

## Wie geht es weiter?

Willst du den Code selbst besitzen, kopier den `OrdersTable` von oben: Er hat unter 200 Zeilen und hängt nur von den beiden TanStack-Paketen ab. Willst du Tastaturfokus, Sticky Header, Karten fürs Smartphone und Auswahl-Banner lieber nicht selbst pflegen, installier die Data Table mit `npx wingo-ui@latest add data-table`. Sie gehört zu Wingo UI Pro, zusammen mit der Virtual List und dem Log Viewer; die Pläne findest du unter [Preise](https://wingo-ui.com/de/pricing).

## 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`
- [Virtual List](https://wingo-ui.com/components/virtual-list) (Pro): A React virtual list for long and endless lists at 60fps, with sticky group headers, infinite loading and scroll restoration. Installation: `npx wingo-ui@latest add virtual-list`
- [Log Viewer](https://wingo-ui.com/components/log-viewer) (Pro): A React log viewer for streaming logs up to 100,000 lines: level colors, level and regex filters, tail follow, ANSI colors and JSON details. Installation: `npx wingo-ui@latest add log-viewer`
- [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`

## Häufige Fragen

### Wie viele Zeilen schafft eine React Virtualized Table?

Es gibt kein festes Limit, denn im DOM stehen nur die Zeilen auf dem Bildschirm plus der Overscan, egal ob die Daten 1.000 oder 100.000 Zeilen haben. Die echten Grenzen sind der Speicher für die Daten, die Zeit, die Sortieren und Filtern in JavaScript brauchen, und die maximale Elementhöhe des Browsers: etwa 16,7 Millionen Pixel in Chromium, also rund 419.000 Zeilen mit 40px.

### Warum verschwindet mein Sticky Header in einer virtualisierten Tabelle?

Meist ist das Element mit overflow: auto nicht das, das die Tabelle umschließt. Dann hat position: sticky keinen Scroll-Container, an dem es haften kann, oder Zeilen, die per Transform positioniert sind, übermalen den Header. Setz die Sticky-Zellen in das scrollende Element, gib ihnen einen Hintergrund und einen z-index, und nutze Spacer-Zeilen statt Transforms, damit die Zeilen im normalen Fluss bleiben.

### Funktioniert TanStack Virtual mit TanStack Table v9?

Ja. TanStack Virtual weiß nichts von Tabellen: Du baust die Tabelle mit useTable, liest table.getRowModel().rows und gibst rows.length als count an den Virtualizer. Sortieren und Filtern passieren vor der Virtualisierung, also zeigt jeder virtuelle Index auf eine Zeile in der sortierten Reihenfolge.

### Wie baue ich Infinite Scroll in eine virtualisierte Tabelle ein?

Beobachte das letzte virtuelle Item: Wenn sein Index nur noch wenige Zeilen von rows.length entfernt ist, es weitere Seiten gibt und keine Anfrage läuft, lädst du die nächste Seite und hängst sie an die Daten an. Der count des Virtualizers wächst mit den Daten, also bleibt die Scrollposition, wo sie war.

### Warum findet Strg+F keine Zeilen in meiner virtualisierten Tabelle?

Zeilen außerhalb des Viewports stehen nicht im DOM, also sieht die Suche im Browser sie nicht. Gib der Tabelle ein eigenes Suchfeld, das stattdessen das Row Model filtert.

---

Quelle: https://wingo-ui.com/de/blog/react-virtualized-table
