Startseite / Artikel / TanStack Query für React: Caching, Neuladen und Mutationen

TanStack Query für React: Caching, Neuladen und Mutationen

Ersetzen Sie den useEffect-Fetch-Boilerplate durch TanStack Query: Abfrageschlüssel, staleTime, gcTime, Mutationen sowie die Situationen, in denen die Bibliothek nicht erforderlich ist.

1840 Wörter

Wie der asynchrone Serverzustand gekachtet, aktualisiert und erneuert wird – und wann es sinnvoll ist, eine Bibliothek hinzuzufügen.

Viele React-Anwendungen beginnen mit dem gleichen Datenlade-Muster: ein useEffect, eine fetch-Anfrage sowie einige useState-Flags für anstehende Aktionen und Fehleranzeigen. Dieses Muster eignet sich für eine erste Umsetzung. Gleichzeitig treten hier oft doppelte Anfragen, veraltete Ansichten sowie kopierte Standardcode-Beispiele auf.

In den folgenden Abschnitten werden diese Mängel erläutert und gezeigt, wie TanStack Query sie behebt. Die einzigen Voraussetzungen sind React-Hooks sowie grundlegende Kenntnisse von TypeScript. Die Beispiele verwenden DummyJSON, eine öffentliche API, die keine API-Schlüssel erfordert.

1. Das Basismuster

Eine Produktliste, die mit den üblichen Hooks erstellt wird, sieht so aus.

import { useEffect, useState } from "react";
type Product = {
  id: number;
  title: string;
  price: number;
};
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    setIsLoading(true);
    fetch("https://dummyjson.com/products?limit=10")
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data: { products: Product[] }) => {
        if (!cancelled) setProducts(data.products);
      })
      .catch((err: Error) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>{error}</p>;
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.title} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

Diese Implementierung ist bereits vorsichtig: Sie verwendet ein cancelled-Flag, sodass eine langsame Antwort den Zustand nach dem Entfernen nicht mehr ändern kann. Viele echte Codebasen überspringen diese Schutzmaßnahme.

2. Was dieser Code nicht berücksichtigt

Der Fetch-Vorgang an sich ist in Ordnung. Die Probleme liegen darum herum.

  • Kein Cache. Wenn man die Route verlässt und zurückkehrt, wird der Netzwerkaufruf erneut ausgeführt – auch dann, wenn sich die Daten nicht geändert haben.
  • Keine Deduplizierung der Anfragen. Drei Komponenten, die dieselbe Produktliste benötigen, senden drei identische Anfragen.
  • Keine Wiederholungsversuche. Ein einziger unterbrochener Verbindungsaufbau führt zu einem Fehlerrauschen in der Benutzeroberfläche – obwohl ein zweiter Versuch erfolgreich wäre.
  • Keine erneute Validierung. Ein Tab, der eine Stunde lang offen bleibt, zeigt weiterhin veraltete Daten an, bis etwas anderes einen Neuladevorgang auslöst.
  • Rennbedingungen. Wenn eine Eingabe wie ein Suchbegriff schnell ändert, kann eine ältere Antwort eine neuere überschreiben. Die cancelled-Flagge hilft bei der Abbrechung; sie löst das Problem überlappender laufender Anfragen jedoch nicht vollständig.
  • Wiederholte Standardcode-Teile. Derselbe Lade-, Fehler- und Aufräumcode wird in jedem Komponenten zur Datenladung dupliziert.
  • Jedes dieser Probleme hat eine bekannte Lösung. Die manuelle Umsetzung dieser Lösungen bedeutet, selbst eine Caching-Schicht zu entwickeln.

    3. Die Idee hinter der Bibliothek

    Es gibt eine nützliche Unterscheidung zwischen zwei Arten von Zustand.

    Klienten-Zustand wird von der Benutzeroberfläche gesteuert: Ob ein Modal-Fenster geöffnet ist, das aktuelle Formularfeld oder das ausgewählte Design. Er ändert sich nur, wenn Ihr Code ihn ändert. useState eignet sich dafür.

    Serverzustand ist ausgeliehen. Er befindet sich in einem Speicher, den Sie nicht kontrollieren. Andere Benutzer können ihn ändern, und die Kopie im Browser ist nur ein Zeitpunktbild. Wenn man dieses Zeitpunktbild ausschließlich in useState speichert, tut man so, als wäre diese vorübergehende Ansicht autoritativ.

    Ausgeliehene Daten benötigen einen speziellen Speicher: einen Ort für die Kopie, ein Ablaufsignal sowie eine Regel, die bestimmt, wann erneut nachgefragt werden soll.

    Ein Kühlschrank ist eine passende Analogie. Milch bleibt zu Hause, sodass man nicht jedes Mal zum Laden gehen muss, um einen Kaffee zuzubereiten – doch sie geht auch bald über den Verfallsdatum hinaus, weshalb man das Datum prüft und nachfüllt, bevor sie verdorben ist. TanStack Query übernimmt diese Funktion für API-Antworten.

    4. Was TanStack Query ist

    TanStack Query verwaltet asynchrone, entfernte Zustände in Browser-Apps. Das Projekt ist unter der MIT-Lizenz verfügbar, kostenlos zu nutzen und kommt in vielen React-Codebasen zum Einsatz.

    Leitfäden, die vor Jahren verfasst wurden, verwenden immer noch den Begriff React Query. Dieser Name blieb bis zur Version 3 gültig. Mit Version 4 wurde das Projekt in TanStack Query umbenannt, nachdem Adapter für Vue, Svelte, Solid und Angular veröffentlicht wurden. Bei React installiert man weiterhin @tanstack/react-query; die aktuelle Hauptversion ist 5.

    Es handelt sich dabei nicht um einen Ersatz für fetch oder axios. Die Abfruffunktion bleibt weiterhin in Ihren Händen. Um diese Funktion herum kümmert sich die Bibliothek um Timing, Speicherung, Aktualität und Fehlerbehandlung.

    5. Einrichtung

    Zwei Schritte reichen aus, um zu starten.

    npm install @tanstack/react-query
    

    Anschließend wird der Baum einmal an der Wurzel eingehüllt:

    // main.tsx
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import App from "./App";
    
    const queryClient = new QueryClient();
    
    export default function Root() {
      return (
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      );
    }
    

    QueryClient ist die Cache-Instanz. QueryClientProvider stellt sie jedem nachgeordneten Komponenten zur Verfügung.

    6. D dieselbe Komponente, neu geschrieben

    import { useQuery } from "@tanstack/react-query";
    
    type Product = {
      id: number;
      title: string;
      price: number;
    };
    
    async function fetchProducts(): Promise<Product[]> {
    
      const res = await fetch("https://dummyjson.com/products?limit=10");
    
      if (!res.ok) throw new Error("Request failed");
      const data: { products: Product[] } = await res.json();
      return data.products;
    }
    
    function ProductList() {
    
      const { data, isPending, isError, error } = useQuery({
        queryKey: ["products"],
        queryFn: fetchProducts,
      });
    
      if (isPending) return <p>Loading…</p>;
      if (isError) return <p>{error.message}</p>;
      return (
        <ul>
          {data.map((product) => (
            <li key={product.id}>
              {product.title} - ${product.price}
            </li>
          ))}
        </ul>
      );
    }
    

    Ungefähr vierzig Zeilen reduzieren sich auf etwa fünfzehn. data wird als Product[] eingegeben, ohne zusätzliche Anmerkung, da die Typisierung aus fetchProducts hervorgeht. Nach den Branches für isPending und isError betrachtet TypeScript data als definiert, weshalb es keine optionale Kettensuche bei map und keine Nicht-Null-Aussage gibt.

    7. Was die kürzere Version bietet

    Verglichen mit den Lücken in Abschnitt 2 decken die Standardwerte bereits die gängigen Fälle ab:

    • Remount zeigt den im Cache gespeicherten Inhalt sofort an und validiert ihn im Hintergrund erneut.
    • Identische laufende Anfragen werden zu einer einzigen Anfrage zusammengefasst.
    • Fehlerhafte Anfragen werden automatisch erneut versucht (standardmäßig drei Versuche mit Verzögerung).
    • Ausgelaufene Einträge werden beim Fokussieren des Fensters, bei einer Wiederherstellung der Netzverbindung und bei einem Neustart neu abgerufen.
  • Nur die neueste Antwort zu einer Schlüsselwerte wird im Cache gespeichert, was Schäden durch Konkurrenzsituationen begrenzt.
  • Ein einziger Hook ersetzt das manuelle Laden sowie den Fehlerzustand.
  • Niemandes Verhalten wurde in der neu geschriebenen Komponente konfiguriert – es handelt sich um die Standardeinstellung der Bibliothek.

    8. Drei Punkte, die man verstehen sollte

    Die meisten anfänglichen Verwirrungen gehen auf die folgenden drei Konzepte zurück.

    Der Abfrageschlüssel

    queryKey ist die Cache-Adresse. Zwei Komponenten, die beide ["products"] verwenden, teilen sich einen Eintrag sowie einen Netzwerkaufruf.

    Grundsatz: jeder Wert, von dem die Abfruffunktion abhängt, muss im Schlüssel enthalten sein.

    function ProductList({ category }: { category: string }) {
      const { data } = useQuery({
        queryKey: ["products", category],
        queryFn: () => fetchProductsByCategory(category),
      });
      // …
    }
    

    Falls category im Schlüssel weggelassen wird, kann ein Änderung der Kategorie dennoch die im Cache gespeicherte Liste der vorherigen Kategorie anzeigen. Dieser Fehler tritt bei Anfängern äußerst häufig auf.

    staleTime und gcTime

    Die Namen klingen ähnlich, bedeuten aber unterschiedliche Dinge.

    staleTime legt das Frische-Fenster fest. Innerhalb dieses Fensters überspringt die Bibliothek Netzwerkarbeiten. Bei dem Standardwert 0 wird ein Ergebnis sofort als veraltet betrachtet: Die Benutzeroberfläche kann weiterhin den im Cache gespeicherten Wert anzeigen, doch jeder Auslöser planet einen Hintergrund-Neuabruf. Erhöhen Sie den Wert, wenn sich die Daten selten ändern:

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      staleTime: 5 * 60 * 1000, // fresh for five minutes
    });
    

    gcTime gibt an, wie lange ungenutzte Daten im Speicher verbleiben, nachdem der letzte Abonnent abgemeldet wurde. Der Standardwert beträgt fünf Minuten. Wenn dieses Fenster abläuft, werden die Daten entfernt und der nächste Besuch beginnt ohne Cache.

    Kurz gesagt: staleTime steuert das Neuabrufen; gcTime steuert die Löschung.

    Wann erfolgt das Neuabrufen

    Standardmäßig wird eine veraltete Abfrage neu geladen, wenn ein Komponente initialisiert wird, wenn das Fenster wieder den Fokus erhält und wenn sich die Netzverbindung erneut herstellt. Jedes dieser Verhaltensweisen kann auf der Client-Seite oder für eine einzelne Abfrage deaktiviert werden:

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      refetchOnWindowFocus: false,
    });
    

    Die Funktion „Neuladen beim Fokus“ überrascht die Nutzer zum ersten Mal, wenn sie damit konfrontiert werden. In der Regel sorgt ihre Aktivierung dafür, dass ein länger genutztes Tab-Blatt aktuell bleibt.

    9. Daten mit useMutation ändern

    useQuery dient zum Lesen. useMutation dient zum Schreiben.

    import { useMutation, useQueryClient } from "@tanstack/react-query";
    
    type NewProduct = {
      title: string;
      price: number;
    };
    
    function AddProductButton() {
    
      const queryClient = useQueryClient();
    
      const { mutate, isPending } = useMutation({
    
        mutationFn: async (product: NewProduct) => {
          const res = await fetch("https://dummyjson.com/products/add", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(product),
          });
    
          if (!res.ok) throw new Error("Could not add product");
          return res.json();
        },
    
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ["products"] });
        },
      });
    
      return (
        <button
          onClick={() => mutate({ title: "New product", price: 25 })}
          disabled={isPending}
        >
          {isPending ? "Saving…" : "Add product"}
        </button>
      );
    }
    

    Die entscheidende Zeile ist invalidateQueries. Dieser Aufruf markiert alle Cache-Einträge mit dem Präfix ["products"] als veraltet, sodass die initialisierten Beobachter umgehend aktuelle Daten anfordern. Lokale Arrays werden nicht manuell aktualisiert, und die Seite muss nicht vollständig neu geladen werden.

    10. Die Entwicklertools

    npm install @tanstack/react-query-devtools
    
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
    

    In der Entwicklung zeigt ein Panel jede Abfrageschlüssel, Status, Nutzlast sowie die letzte Ladezeit an. Wenn man beobachtet, wie sich die Einträge zwischen „frisch“ und „veraltet“ wechseln, während man durch die Optionen klickt, lernt man das Caching-Modell schneller als allein durch Lesen. Das Paket wird in Produktversionen automatisch entfernt.

    Häufige Fehler

    • Variable aus dem Abfrageschlüssel wegzulassen. Wenn die Abfragefunktion einen Wert liest, muss dieser im Schlüssel enthalten sein.
    • useQuery innerhalb eines useEffect zu verwenden. Der Hook läuft bereits während der Darstellung ab; es gibt nichts Zusätzliches, was ausgelöst werden müsste.
    • Die Bibliothek für reine Client-States zu nutzen. Formfelder und Modal-Flags gehören in useState.
    • Überall staleTime: Infinity zu setzen. Dadurch wird die erneute Validierung deaktiviert, was den größten Teil des Vorteils beseitigt.
  • Kopieren von data in den lokalen Zustand. Dadurch gibt es zwei Kopien, und die angezeigte Version hört auf, den Cache zu verfolgen.
  • 12. Wenn man es vielleicht nicht benötigt

    Falls die App nur einen Endpunkt auf einer einzigen Seite aufruft, können Provider und Hooks übertrieben sein im Vergleich zum tatsächlichen Bedarf.

    Falls das Framework bereits eine Datenlage bereitstellt – Next.js-Serverkomponenten oder ein Router mit Loadern – ist bereits ein Teil der Arbeit erledigt. TanStack Query kann trotzdem bei interaktiven Client-Anfragen helfen, ist aber nicht zwingend erforderlich.

    Für Zustände, die niemals den Browser verlassen, sollte man ein anderes Tool wählen.

    13. Weitere Schritte

    Die tägliche Arbeit wird durch die oben genannten Konzepte abgedeckt. Tiefergehende Themen finden sich an anderen Stellen:

    • Offizielle Dokumentation – Referenzmaterialien und interaktive Demos
    • Gespaltene und unendliche Listen – verwenden Sie useInfiniteQuery, wenn beim Scrollen weitere Seiten geladen werden
    • Anfragen, die auf andere warten – schützen Sie eine nachfolgende Anfrage mit enabled, bis eine Voraussetzung abgeschlossen ist
    • Optimistische Benutzeroberfläche – rendern Sie das gewünschte Ergebnis, bevor die Antwort auf die Änderung zurückkommt

    Bedenken Sie stets folgendes Prinzip: Ferndaten gehören in einen Cache, nicht in den Zustand von Komponenten. Wenn man dieses Modell im Hinterkopf hat, ergibt sich der Rest der API von selbst.