Zum Inhalt springen
    WingoUI
    KomponentenVorlagenDokuPreiseKI-AgentenBlogChangelog
    1. Startseite
    2. Blog
    3. Komponenten-Guides
    4. React Virtualized Table: 100.000 Zeilen mit TanStack Virtual
    Read in English
    1. Blog
    2. Komponenten-Guides
    3. React Virtualized Table: 100.000 Zeilen mit TanStack Virtual
    Komponenten-Guides
    Komponenten-Guides

    React Virtualized Table: 100.000 Zeilen mit TanStack Virtual

    SR
    Serban Rusu · Gründer von Wingo UI
    16. Okt. 2026 · 13 Min. Lesezeit

    Auf dieser Seite

    0%
    1. Warum friert eine Tabelle mit 100.000 Zeilen React ein?
    2. Wie entscheidet TanStack Virtual, welche Zeilen es rendert?
    3. Wie baue ich eine virtualisierte Tabelle mit TanStack Table v9?
      1. Spacer-Zeilen statt Transforms
      2. Ein Sticky Header, der an seinem Platz bleibt
      3. Der erste Bildschirm auf dem Server
    4. Wie bleibt die Tastaturnavigation intakt, wenn Zeilen verschwinden?
    5. Wie funktioniert die Auswahl, wenn die meisten Zeilen nicht im DOM stehen?
    6. Wie baue ich Infinite Scroll in eine virtualisierte Tabelle ein?
    7. Was geht bei 100.000 Zeilen immer noch kaputt?
    8. Gibt es eine React-Datentabelle mit eingebauter Virtualisierung?
    9. Wann nehme ich eine virtuelle Liste statt einer Tabelle?
    10. Wie geht es weiter?
    11. Häufige Fragen
      1. Wie viele Zeilen schafft eine React Virtualized Table?
      2. Warum verschwindet mein Sticky Header in einer virtualisierten Tabelle?
      3. Funktioniert TanStack Virtual mit TanStack Table v9?
      4. Wie baue ich Infinite Scroll in eine virtualisierte Tabelle ein?
      5. Warum findet Strg+F keine Zeilen in meiner virtualisierten Tabelle?

    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.

    Veröffentlicht am 16. Okt. 2026

    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 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 (opens in a new tab) 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:

    AnsatzWie die Zeilen platziert werdenWas du behältstWas kaputtgeht
    Absolute Zeilen (das Beispiel für virtualisierte Zeilen von TanStack Table (opens in a new tab))tbody bekommt die Gesamthöhe, jedes tr ist position: absolute mit translateY(start)Unterschiedliche Zeilenhöhen über measureElementDas native Tabellenlayout: Die Beispiele stellen die Tabelle auf display: grid und die Zeilen auf display: flex um, also setzt du jede Spaltenbreite selbst
    Spacer-ZeilenEin leeres <tr> über den gerenderten Zeilen und eins darunter, jeweils so hoch wie die Zeilen, für die es stehtNative Spaltenbreiten, Header mit position: sticky, colSpan, normale RahmenFunktioniert 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 (opens in a new tab), 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 (opens in a new tab) 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 (opens in a new tab) 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 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.

    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
    $ npx wingo-ui@latest add data-table
    ProDoku: 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 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.

    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
    $ npx wingo-ui@latest add virtual-list
    ProDoku: 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 hält standardmäßig bis zu 100.000 Zeilen (maxLines) in einem Ringpuffer mit virtualisiertem Body. Der Guide zum React Log Viewer erklärt ihn. Brauchst du nur gestyltes Tabellen-Markup ohne Sortieren oder Virtualisierung, nimm die Table-Primitives, aus denen die Data Table gebaut ist.

    Mehr zur Tabelle selbst: Das Tutorial zur Data Table mit TanStack Table v9 behandelt Filter, Sammelaktionen und Pagination auf dem Server, und der Artikel über responsive Tabellen-Layouts für Mobile erklärt, warum Zeilen unter 768px zu Karten werden. Alle Komponenten-Guides findest du in der Kategorie Komponenten-Guides.

    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.

    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
    • Virtual List

      A React virtual list for long and endless lists at 60fps, with sticky group headers, infinite loading and scroll restoration.

      Pro
    • Log Viewer

      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.

      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

    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.

    • TanStack Virtual
    • TanStack Table
    • Virtualisierung
    • Datentabelle
    • Barrierefreiheit
    • React

    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 Log Viewer: 100.000 Zeilen streamen mit Regex-Suche

    Bau einen React Log Viewer, der 100.000 Zeilen streamt: Virtualisierung, Level-Filter, Regex-Suche und ein Follow-Modus, der beim Hochscrollen sofort pausiert.

    SRSerban Rusu·16. Okt. 2026·8 Min. Lesezeit
    Komponenten-Guides

    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.

    SRSerban Rusu·9. Okt. 2026·12 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
    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