React Virtualized Table: 100.000 Zeilen mit TanStack Virtual

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:
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:
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.
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 liestgetIsAllRowsSelected()undgetIsSomeRowsSelected(). In v9 ist die zweite auch danntrue, 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
measureElementund eine möglichst genaueestimateSize, 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.
Die Demo unten lädt 1.240 Kunden, ohne Pagination, mit Virtualisierung und einer Scrollbox von 520px.
$ npx wingo-ui@latest add data-tableFü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.
$ npx wingo-ui@latest add virtual-listStreamender 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.
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


