Pagination ohne Schmerzen
Lange Listen mit Nachladen beim Scrollen sind eine der häufigsten UI-Anforderungen, aber naive Implementierungen scheitern regelmäßig an Duplikaten, Speicherverbrauch und inkonsistenter Scroll-Position. TanStack Querys Infinite Query löst diese Probleme mit einer klaren Seiten-Struktur, cursorbasierter Navigation und lässt sich mit Virtualisierung kombinieren, um auch zehntausende Einträge flüssig darzustellen.
Inhaltsverzeichnis
- 1. Warum naive Pagination in React regelmäßig scheitert
- 2. useInfiniteQuery: Grundlagen und Seiten-Struktur
- 3. Cursor- vs. Offset-Pagination im Infinite-Query-Kontext
- 4. Bidirektionales Laden: nach oben und unten paginieren
- 5. Lade-Trigger: IntersectionObserver statt Scroll-Handler
- 6. Virtualisierung: Infinite Query trifft auf zehntausende Zeilen
- 7. Invalidierung und Refetch bei paginierten Daten
- 8. Fehlerbehandlung und Retry pro Seite
- 9. Pagination-Strategien im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum naive Pagination in React regelmäßig scheitert
Eine Infinite Query klingt zunächst wie eine einfache Erweiterung von useQuery: Statt einer einzelnen Seite lädt man einfach mehrere Seiten nacheinander nach. In der Praxis scheitern selbstgebaute Lösungen regelmäßig an denselben drei Problemen: Duplikate, wenn sich Daten zwischen zwei Ladevorgängen verschieben, unkontrolliert wachsender Speicherverbrauch bei sehr langen Listen, und ein Verlust der Scroll-Position bei jedem Refetch. Diese Probleme zeigen sich selten in der Entwicklung mit wenigen Testdaten, sondern erst in Produktion mit echten, sich ständig ändernden Datensätzen.
TanStack Querys useInfiniteQuery löst genau diese Klasse von Problemen, indem jede geladene Seite als eigenständiges, adressierbares Cache-Objekt gespeichert wird, verknüpft über einen expliziten Cursor oder Seiten-Parameter. Dieser Artikel zeigt, wie eine Infinite Query mit Cursor-Pagination, bidirektionalem Laden und Virtualisierung zusammenspielt, um Listen mit zehntausenden Einträgen ohne Performance-Einbußen darzustellen.
2. useInfiniteQuery: Grundlagen und Seiten-Struktur
useInfiniteQuery unterscheidet sich von useQuery in einem entscheidenden Punkt: Der Cache-Eintrag für eine Infinite Query speichert nicht einen einzelnen Datenblock, sondern ein Array von Seiten, jede mit ihren eigenen Metadaten für die nächste und vorherige Seite. Die queryFn erhält einen pageParam, der bestimmt, welche Seite geladen wird, und die Funktion getNextPageParam extrahiert aus der zuletzt geladenen Seite den Parameter für die nächste Anfrage.
Diese explizite Trennung zwischen Seiten-Daten und Seiten-Metadaten ist der Kern, warum eine Infinite Query robuster ist als eine selbstgebaute Lösung mit einem einzigen, wachsenden Array. Jede Seite bleibt individuell identifizierbar, was Refetches einzelner Seiten, gezielte Invalidierung und korrektes Verhalten bei Netzwerkfehlern auf einer einzelnen Seite ermöglicht, ohne die bereits erfolgreich geladenen Seiten zu beeinträchtigen.
// useArticleFeed.ts — Basic infinite query with cursor-based pagination
import { useInfiniteQuery } from "@tanstack/react-query";
function useArticleFeed() {
return useInfiniteQuery({
queryKey: ["articles"],
queryFn: ({ pageParam }) => fetchArticles({ cursor: pageParam, limit: 20 }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}
// Component usage: pages is an array of page results, each with its own items
function ArticleFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useArticleFeed();
const articles = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{articles.map((a) => <ArticleCard key={a.id} article={a} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Lädt..." : "Mehr laden"}
</button>
)}
</div>
);
}
3. Cursor- vs. Offset-Pagination im Infinite-Query-Kontext
Die Wahl zwischen Cursor- und Offset-Pagination hat direkte Konsequenzen für die Korrektheit einer Infinite Query. Offset-Pagination, bei der jede Seite über limit und offset angefragt wird, bricht, sobald sich zwischen zwei Ladevorgängen neue Einträge vor der aktuellen Position einreihen: Ein Eintrag verschiebt sich in eine bereits geladene Seite, und beim Nachladen erscheint er doppelt oder wird komplett übersprungen. Bei einem Feed mit häufigen neuen Einträgen ist dieses Problem nicht die Ausnahme, sondern der Normalfall.
Cursor-Pagination vermeidet dieses Problem grundsätzlich, weil jede Seite über einen stabilen Referenzpunkt angefragt wird, etwa die ID oder den Zeitstempel des letzten Eintrags der vorherigen Seite, statt über eine numerische Position. Neue Einträge vor dem Cursor beeinflussen die bereits geladenen Seiten einer Infinite Query nicht, weil der nächste Request relativ zum letzten bekannten Eintrag bleibt, nicht relativ zur absoluten Position in der Gesamtliste. Für jede Liste, die sich während der Nutzung ändern kann, ist Cursor-Pagination deshalb die robustere Wahl.
4. Bidirektionales Laden: nach oben und unten paginieren
Manche Anwendungsfälle, etwa ein Chat-Verlauf, der initial in der Mitte der Konversation öffnet, oder ein Feed mit Live-Updates am oberen Rand, brauchen eine Infinite Query, die in beide Richtungen laden kann. TanStack Query unterstützt dies über getPreviousPageParam zusätzlich zu getNextPageParam, wodurch fetchPreviousPage als Gegenstück zu fetchNextPage verfügbar wird und neue Seiten am Anfang statt am Ende des pages-Arrays einfügt.
Die größte Herausforderung bei bidirektionalem Laden ist nicht die Datenabfrage selbst, sondern das Erhalten der Scroll-Position, wenn neue Inhalte am oberen Rand eingefügt werden. Ohne Gegenmaßnahme springt der sichtbare Ausschnitt nach oben, sobald der Browser neue DOM-Elemente über der aktuellen Position einfügt. Die gängige Lösung ist, vor dem Einfügen die aktuelle Scroll-Höhe zu messen, und nach dem Rendern der neuen Elemente die Differenz zur neuen Scroll-Höhe auf die Scroll-Position zu addieren, sodass der sichtbare Inhalt stabil bleibt.
// ChatHistory.tsx — Preserve scroll position when prepending older messages
function ChatHistory({ conversationId }) {
const containerRef = useRef<HTMLDivElement>(null);
const { data, fetchPreviousPage, hasPreviousPage } = useMessagesInfiniteQuery(conversationId);
const loadOlder = async () => {
const container = containerRef.current;
if (!container) return;
const previousHeight = container.scrollHeight;
await fetchPreviousPage();
// Restore the visual position after new items were prepended
requestAnimationFrame(() => {
const heightDiff = container.scrollHeight - previousHeight;
container.scrollTop += heightDiff;
});
};
return (
<div ref={containerRef} className="overflow-y-auto h-full">
{hasPreviousPage && <button onClick={loadOlder}>Ältere Nachrichten laden</button>}
{data?.pages.flatMap((p) => p.messages).map((m) => <Message key={m.id} message={m} />)}
</div>
);
}
5. Lade-Trigger: IntersectionObserver statt Scroll-Handler
Ein häufiger Anfängerfehler beim Bau einer Infinite Query ist, das Nachladen an einen scroll-Event-Handler zu binden, der die Scroll-Position bei jedem Pixel-Wert prüft. Dieser Ansatz feuert dutzende Male pro Sekunde, blockiert bei aufwendiger Logik den Main-Thread und führt zu ruckelndem Scrollen, besonders auf leistungsschwächeren Geräten. Der robustere Ansatz nutzt die IntersectionObserver-API, die einen unsichtbaren Sentinel-Anker am Ende der Liste beobachtet und nur dann feuert, wenn dieser Anker tatsächlich in den sichtbaren Bereich eintritt.
Ein Sentinel-Element am Ende der Liste in Kombination mit IntersectionObserver entkoppelt die Lade-Logik komplett vom Scroll-Event und läuft nebenläufig zum Rendering, ohne den Main-Thread zu blockieren. Für React lässt sich dieser Mechanismus sauber in einen wiederverwendbaren useIntersectionObserver-Hook kapseln, der von jeder Infinite Query-Komponente im Projekt genutzt werden kann, statt die Observer-Logik in jeder Liste erneut zu implementieren.
6. Virtualisierung: Infinite Query trifft auf zehntausende Zeilen
Eine Infinite Query allein löst das Nachladeproblem, aber nicht das Rendering-Problem: Sobald tausende Elemente im DOM stehen, wird das Scrollen unabhängig von der Datenquelle langsam, weil der Browser jedes einzelne DOM-Element layouten und rendern muss. Die Lösung ist Virtualisierung, etwa mit TanStack Virtual, das nur die aktuell sichtbaren Elemente plus einen kleinen Puffer tatsächlich ins DOM rendert und den restlichen Platz durch einfache Abstandshalter simuliert.
Die Kombination aus Infinite Query und Virtualisierung erfordert etwas Sorgfalt bei der Integration: Der virtualisierte Container muss wissen, wie viele Elemente insgesamt existieren, auch wenn nicht alle geladen sind, damit die Scrollbar-Größe korrekt berechnet wird. Eine gängige Lösung ist, die Gesamtzahl vom Server mitzuliefern und Platzhalter-Elemente für noch nicht geladene Bereiche zu rendern, die beim Erreichen automatisch das Nachladen der entsprechenden Seite auslösen.
// VirtualizedFeed.tsx — Combining useInfiniteQuery with TanStack Virtual
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualizedFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage } = useArticleFeed();
const items = data?.pages.flatMap((p) => p.items) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length, // +1 for loading row
getScrollElement: () => parentRef.current,
estimateSize: () => 96,
overscan: 5,
});
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage]);
return (
<div ref={parentRef} className="h-screen overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} style={{ transform: `translateY(${row.start}px)` }}>
{items[row.index] ? <ArticleCard article={items[row.index]} /> : "Lädt..."}
</div>
))}
</div>
</div>
);
}
7. Invalidierung und Refetch bei paginierten Daten
Eine Infinite Query zu invalidieren, etwa nach dem Anlegen eines neuen Eintrags, erfordert Sorgfalt, weil standardmäßig alle bisher geladenen Seiten neu abgefragt werden. Bei einer Liste mit zwanzig geladenen Seiten löst eine einzelne Invalidierung zwanzig parallele Requests aus, was sowohl das Backend belastet als auch unnötig Bandbreite verbraucht. TanStack Query erlaubt mit refetchType: "none" in Kombination mit gezieltem setQueryData, nur den relevanten Teil der bereits geladenen Seiten zu aktualisieren, statt alle neu zu laden.
Für den häufigen Fall, dass ein neuer Eintrag ganz oben in der Liste erscheinen soll, ist es oft einfacher, das neue Element direkt in die erste Seite des Caches einzufügen, statt eine komplette Invalidierung auszulösen. Diese gezielte Cache-Manipulation hält die Infinite Query konsistent, ohne dass Nutzerinnen einen kurzen Ladezustand über die gesamte, bereits gescrollte Liste hinweg sehen.
8. Fehlerbehandlung und Retry pro Seite
Bei einer Infinite Query kann eine einzelne Seite fehlschlagen, ohne dass die bereits erfolgreich geladenen Seiten ungültig werden. TanStack Query behandelt einen Fehler beim Nachladen separat vom Gesamtzustand der Query: isFetchNextPageError zeigt an, dass speziell der letzte Nachladeversuch fehlgeschlagen ist, während die vorherigen Seiten weiterhin normal angezeigt werden. Diese Granularität erlaubt eine UI, die nur am Ende der Liste einen Fehler mit Retry-Button zeigt, statt die komplette Ansicht durch eine generische Fehlermeldung zu ersetzen.
Für instabile Netzwerkverbindungen lohnt sich eine leicht erhöhte retry-Anzahl speziell für Nachladevorgänge, kombiniert mit exponentiellem Backoff über die retryDelay-Option. Da eine Infinite Query oft beim Scrollen auf mobilen Geräten mit wechselnder Verbindungsqualität genutzt wird, reduziert dieses Verhalten sichtbare Fehler erheblich, ohne bei einem tatsächlich dauerhaft ausgefallenen Server endlos zu retryen.
9. Pagination-Strategien im direkten Vergleich
Die folgende Tabelle vergleicht die gängigen Pagination-Ansätze für React-Listen, mit Fokus auf Konsistenz bei sich ändernden Daten und Eignung für Infinite Query-Muster.
| Ansatz | Konsistenz bei neuen Daten | Sprung zu Seite N | Eignung für Infinite Scroll |
|---|---|---|---|
| Offset-Pagination | Anfällig für Duplikate/Lücken | Einfach | Eingeschränkt geeignet |
| Cursor-Pagination | Stabil | Schwierig ohne Zwischenschritte | Sehr gut geeignet |
| Keyset mit Zeitstempel | Stabil | Schwierig | Sehr gut, auch bidirektional |
| Klassische Seitenzahlen | Anfällig | Sehr einfach | Ungeeignet |
Für klassische, seitenbasierte Navigation mit sichtbaren Seitenzahlen bleibt Offset-Pagination die einfachste Lösung, sofern die zugrunde liegenden Daten sich selten ändern. Für jede Infinite Query mit Nachladen beim Scrollen ist Cursor- oder Keyset-Pagination die robustere Wahl, weil sie unabhängig von zwischenzeitlichen Änderungen an der Datenmenge konsistent bleibt.
Mironsoft
Performante Listen und Pagination-Architektur für React-Anwendungen
Lange Listen, die auch bei 50.000 Einträgen flüssig scrollen?
Wir kombinieren useInfiniteQuery mit Cursor-Pagination und Virtualisierung, damit eure Feeds und Tabellen unabhängig von der Datenmenge performant bleiben.
Pagination-Audit
Analyse bestehender Offset-Pagination auf Duplikate und Inkonsistenzen
Virtualisierung
TanStack Virtual Integration für Listen mit zehntausenden Zeilen
Bidirektionales Laden
Chat-Verläufe und Feeds mit stabiler Scroll-Position in beide Richtungen
10. Zusammenfassung
Eine Infinite Query mit TanStack Query löst die typischen Pagination-Probleme, indem sie jede Seite als eigenständiges Cache-Objekt speichert, statt einen einzigen, wachsenden Array zu pflegen. Cursor-Pagination bleibt konsistent, wenn sich Daten zwischen zwei Ladevorgängen ändern, während Offset-Pagination in diesem Szenario zu Duplikaten oder Lücken neigt. Bidirektionales Laden über getPreviousPageParam erfordert zusätzliche Sorgfalt beim Erhalten der Scroll-Position, ist aber für Chat-Verläufe und Live-Feeds unverzichtbar.
IntersectionObserver statt Scroll-Handler entkoppelt das Nachladen vom Main-Thread, und die Kombination mit Virtualisierung über TanStack Virtual macht eine Infinite Query auch bei zehntausenden Einträgen performant. Gezielte Cache-Updates statt kompletter Invalidierung vermeiden unnötige Requests bei neuen Einträgen, und granulare Fehlerbehandlung pro Seite verhindert, dass ein einzelner fehlgeschlagener Nachladevorgang die gesamte Liste unbrauchbar macht.
Infinite Query Patterns mit TanStack Query — Das Wichtigste auf einen Blick
Seiten-Struktur
Jede Seite ist ein eigenständiges Cache-Objekt mit getNextPageParam für stabile Navigation.
Cursor statt Offset
Bleibt konsistent bei sich ändernden Daten, vermeidet Duplikate und Lücken.
Virtualisierung
TanStack Virtual rendert nur sichtbare Elemente, auch bei zehntausenden Zeilen performant.
Lade-Trigger
IntersectionObserver statt Scroll-Handler für ruckelfreies Nachladen.