Infinite Query Patterns mit TanStack Query: Pagination ohne Schmerzen
AI generated
</>
{ }
React · TanStack Query · Pagination
Infinite Query Patterns mit TanStack Query
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.

17 Min. Lesezeit useInfiniteQuery · Cursor-Pagination · Virtualisierung · Bidirektional React 19 · TanStack Query v5

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.

11. FAQ: Infinite Query Patterns mit TanStack Query

1Was speichert eine Infinite Query im Cache?
Ein Array von Seiten mit eigenen Metadaten für nächste und vorherige Seite, statt eines einzigen wachsenden Arrays.
2Warum bricht Offset-Pagination?
Positionen verschieben sich bei neuen Einträgen, was zu Duplikaten oder übersprungenen Einträgen führt.
3Wann brauche ich bidirektionales Laden?
Bei Chat-Verläufen oder Feeds mit Live-Updates am oberen Rand, über getPreviousPageParam.
4Scroll-Sprünge beim Vorspann verhindern?
Scroll-Höhe vorher messen, Differenz nach dem Rendern berechnen und auf die Scroll-Position addieren.
5Warum IntersectionObserver statt Scroll-Events?
Scroll-Events feuern zu häufig und blockieren den Main-Thread, IntersectionObserver nur bei tatsächlicher Sichtbarkeit.
6Brauche ich immer Virtualisierung?
Nicht bei wenigen hundert Elementen. Ab mehreren tausend DOM-Elementen wird sie für flüssiges Scrollen notwendig.
7Wie invalidiere ich effizient?
Mit setQueryData gezielt die betroffene Seite aktualisieren statt eine komplette Neuladung auszulösen.
8Was passiert bei fehlgeschlagener Seite?
isFetchNextPageError zeigt den Fehler isoliert, ohne bereits geladene Seiten ungültig zu machen.
9Ist Cursor-Pagination immer besser?
Für Infinite Scroll ja. Für Seitenzahlen-Navigation mit Sprung zu Seite N bleibt Offset einfacher.
10Gesamthöhe ohne alle Seiten zu laden?
Der Server liefert die Gesamtanzahl mit, Platzhalter für nicht geladene Bereiche lösen automatisch Nachladen aus.