Strona główna / Artykuły / TanStack Query dla React: cacheowanie, ponowne pobieranie danych i mutacje

TanStack Query dla React: cacheowanie, ponowne pobieranie danych i mutacje

Zastąp standardową strukturę useEffect fetch biblioteką TanStack Query: klucze zapytań, staleTime, gcTime, mutacje oraz sytuacje, gdy biblioteka jest niepotrzebna.

1840 słów

Jak kierowany asynchronicznie stan serwera jest cacheowany, odświeżany i aktualizowany — oraz kiedy warto dodać tę bibliotekę.

Wiele aplikacji React zaczyna się od tego samego schematu ładowania danych: useEffect, fetch oraz kilka flag useState do pokazywania informacji o czekających zapytaniach i błędach. Ten wzorzec jest odpowiedni na początek. To również tam pojawiają się duplikowane żądania, przestarzałe ekraniki oraz skopiowany szablon kodu.

Poniższe sekcje omawiają te problemy i pokazują, w jaki sposób TanStack Query je rozwiązuje. Jedyne wymagania to hooki React oraz podstawowy TypeScript. Przykłady korzystają z DummyJSON, publicznej API, która nie wymaga klucza API.

1. Podstawowy wzorzec

Lista produktów napisana przy użyciu standardowych hooków wygląda w ten sposób.

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>
  );
}

To rozwiązanie jest już dobre: wykorzystuje flagę cancelled, dzięki czemu powolna odpowiedź nie może zmienić stanu po odłączeniu. Wiele rzeczywistych baz kodu pomija tę ochronę.

2. Co ten kod nie obsługuje

Sam żądanie fetch jest w porządku. Problemy pojawiają się wokół niego.

  • Brak cache’u. Po opuszczeniu strony i powrocie żądanie sieciowe jest wysyłane ponownie, nawet jeśli treść nie uległa zmianie.
  • Brak unikalizacji żądań. Trzy komponenty, które potrzebują tej samej listy produktów, wysyłają trzy identyczne żądania.
  • Brak prób ponownych. Jedno przerwane połączenie powoduje błędy w interfejsie, mimo że druga próba mogłaby się udać.
  • Brak ponownej walidacji. Karta pozostawiona otwarta przez godzinę nadal pokazuje dane sprzed godziny, dopóki coś innego nie wywoła ponownego załadowania.
  • Sytuacje konkurencyjne. Gdy dane wejściowe, takie jak słowo kluczowe w wyszukiwaniu, zmieniają się szybko, starsza odpowiedź może nadpisać nowszą. Flaga cancelled pomaga przy odłączaniu; nie rozwiązuje jednak w pełni problemu nakładających się zapytań w trakcie przetwarzania.
  • Powtarzający się kod szablonowy. Ten sam kod odpowiedzialny za ładowanie, obsługę błędów i czyszczenie jest duplikowany w każdym komponencie do ładowania danych.
  • Każdy z tych problemów ma znane rozwiązanie. Ręczna implementacja tych poprawek oznacza konieczność stworzenia własnej warstwy cache’owania.

    3. Idea stojąca za biblioteką

    Korzystne jest rozróżnienie na dwa rodzaje stanu.

    Stan klienta należy do interfejsu użytkownika: czy modala jest otwarta, które pole formularza jest aktualne, jaki temat został wybrany. Zmienia się on tylko wtedy, gdy twój kod to zmieni. Do tego zadania nadaje się useState.

    Stan serwera jest pożyczony. Znajduje się w magazynie, którym nie kontrolujesz. Inni użytkownicy mogą go zmieniać, a kopia w przeglądarce to jedynie zrzut ekranu. Przechowywanie tego zrzutu wyłącznie w useState sprawia wrażenie, że tymczasowy widok ma autorytet.

    Dane pożyczone wymagają dedykowanego magazynu: miejsca na kopię, sygnału wskazującego czas trwania danych oraz reguły określającej, kiedy ponownie je pobrać.

    Prawidłową analogią jest lodówka. Mleko pozostaje w domu, więc nie musisz za każdą filiżanką kawy chodzić do sklepu, ale ma datę ważności, więc sprawdzasz ją i uzupełniasz zapasy, zanim się zepsuje. TanStack Query pełni tę rolę w odniesieniu do odpowiedzi API.

    4. Czym jest TanStack Query

    TanStack Query zarządza asynchronicznym stanem zdalnym w aplikacjach przeglądarkowych. Projekt jest licencjonowany na zasadach MIT, można go używać bezpłatnie i jest powszechnie stosowany w kodach opartych na React.

    Przewodniki napisane lata temu nadal wspominają o React Query. Ta nazwa obowiązywała aż do wersji 3. Wersja 4 zmieniła nazwę projektu na TanStack Query po wydaniu adapterów dla Vue, Svelte, Solid i Angular. W React nadal instaluje się @tanstack/react-query; aktualną jest główna wersja 5.

    Nie zastępuje on fetch ani axios. Funkcja żądania pozostaje pod Twoją kontrolą. Biblioteka decyduje wokół tej funkcji o czasie wykonywania, przechowywaniu danych, ich aktualności oraz radzeniu sobie z błędami.

    5. Konfiguracja

    Aby zacząć, wystarczy dwa kroki.

    npm install @tanstack/react-query
    

    Następnie otocz drzewo komponentów raz na poziomie korzenia:

    // 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 to instancja pamięci cache. QueryClientProvider udostępnia ją każdemu komponentowi potomnemu.

    6. Ten sam komponent, przepisany

    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>
      );
    }
    

    Około czterdzieści wierszy skraca się do około piętnastu. Zmienna data jest definiowana jako Product[] bez dodatkowych adnotacji, ponieważ jej typ wynika bezpośrednio z funkcji fetchProducts. Po obsłudze warunków isPending i isError TypeScript traktuje data jako już zdefiniowaną, więc nie ma konieczności stosowania opcjonalnego łańcuchowania w metodzie map ani twierdzeń typu non-null.

    7. Co daje krótsza wersja

    W porównaniu z brakami opisanymi w sekcji 2, domyślne ustawienia już obejmują najczęściej występujące przypadki:

    • Remount natychmiast wyświetla zapisany payload i ponownie go waliduje w tle.
    • Tożsame zapytania trwające realizację łączą się w jedną prośbę.
    • Nieudane próby są automatycznie powtarzane (trzy próby domyślnie, z opóźnieniami).
    • Zastarzałe dane są ponownie pobierane po skupieniu uwagi na oknie, przy ponownym połączeniu sieci lub po ponownym uruchomieniu.
  • Tylko najnowsza odpowiedź dla danego klucza jest zapisywana w pamięci cache, co ogranicza problemy związane z konkurencją.
  • Jeden mechanizm zastępuje ręczne ładowanie oraz obsługę stanów błędowych.
  • Żadne z tych zachowań nie zostało skonfigurowane w przepisanej komponencie – to standardowa wartość domyślna biblioteki.

    8. Trzy kwestie warte zrozumienia

    Większość wczesnych nieporozumień wynika z następnych trzech koncepcji.

    Klucz zapytania

    queryKey to adres w pamięci cache. Dwie komponenty, które obie używają ["products"], dzielą się jednym wpisem oraz jednym żądaniem sieciowym.

    Zasada ogólna: każda wartość, od której zależy funkcja zapytania, musi znaleźć się w kluczu.

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

    Jeśli z klucza pominięto category, zmiana kategorii może nadal wyświetlić listę z poprzedniej kategorii przechowywaną w cache. Ten błąd jest niezwykle powszechny wśród początkujących użytkowników.

    staleTime i gcTime

    Nazwy te brzmią podobnie, ale oznaczają różne rzeczy.

    staleTime określa okno aktualności. W ramach tego okna biblioteka pomija prace sieciowe. Przy domyślnej wartości 0 wynik staje się natychmiast przestarzały: interfejs może nadal wyświetlać wartość z pamięci podręcznej, ale każde wyzwanie uruchamia tło aktualizacji. Zwiększ tę wartość, gdy dane rzadko się zmieniają:

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

    gcTime określa, jak długo nieużywane dane pozostają w pamięci po odłączeniu ostatniego subskrybenta. Domyślna wartość to pięć minut. Gdy to okno się kończy, wpis jest usuwany, a następna wizyta rozpoczyna się od zera.

    Krótko mówiąc: staleTime reguluje ponowne pobieranie danych; gcTime reguluje ich usuwanie.

    Kiedy następuje ponowne pobieranie

    Domyślnie stary zapytanie jest ponownie pobierane, gdy komponent zostaje załadowany, gdy okno ponownie uzyskuje uwagę użytkownika oraz gdy sieć się łączy. Każde z tych zachowań można wyłączyć zarówno na poziomie klienta, jak i dla konkretnego zapytania:

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

    Funkcja ponownego pobierania danych po odzyskaniu uwagi zaskakuje ludzi przy pierwszym spotkaniu. Zazwyczaj to właśnie dzięki jej włączeniu karta otwarta od dłuższego czasu pozostaje aktualna.

    9. Zmiana danych za pomocą useMutation

    useQuery służy do odczytu danych. useMutation służy do ich zapisu.

    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>
      );
    }
    

    Kluczowym elementem jest metoda invalidateQueries. Ta funkcja oznacza jako przestarzałe wszystkie wpisy w pamięci cache o prefiksie ["products"], dzięki czemu załadowane obserwatory natychmiast żądają nowych danych. Tablice lokalne nie są ręcznie aktualizowane, a strona nie wymaga pełnego ponownego załadowania.

    10. Narzędzia deweloperskie

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

    W fazie rozwoju panel pokazuje każdą kluczową wartość zapytania, jego status, zawartość oraz czas ostatniego pobrania. Obserwowanie, jak pozycje przechodzą między stanem aktualnym a przestarzałym podczas klikania, uczy modelu cache szybciej niż sama lektura. Pakiet jest automatycznie usuwany z wersji produkcyjnych.

    Częste błędy

    • Pomijanie zmiennych w kluczu zapytania. Jeśli funkcja zapytania odczytuje jakąś wartość, klucz musi ją zawierać.
    • Umieszczanie useQuery wewnątrz useEffect. Hook ten już działa podczas renderowania; nie ma potrzeby niczego dodatkowo „uruchamiać”.
    • Używanie biblioteki do zarządzania wyłącznie stanem po stronie klienta. Pola formularza i flagi modali powinny być przechowywane za pomocą useState.
    • Ustawianie staleTime: Infinity we wszystkich miejscach. To wyłącza ponowną weryfikację, co eliminuje większość korzyści.
  • Kopiowanie data do lokalnego stanu. W ten sposób utrzymujesz dwie kopie, a ta renderowana przestaje śledzić pamięć cache.
  • 12. Kiedy może się to okazać niepotrzebne

    Jeśli aplikacja korzysta z jednego endpointu na jednym ekranie, użycie dostawców i hooki może być bardziej skomplikowane, niż wymaga to problemu.

    Jeśli framework już zapewnia warstwę danych — komponenty serwerowe Next.js lub router z loaderami — część pracy jest już wykonana. TanStack Query nadal może pomóc przy interaktywnych żądaniach po stronie klienta, ale nie jest to obowiązkowe.

    Dla stanu, który nigdy nie opuszcza przeglądarki, wybierz inny narzędzie.

    13. Dokąd iść dalej

    Powyższe koncepcje obejmują codzienną pracę. Bardziej zaawansowane tematy znajdują się w innych miejscach:

    • Oficjalna dokumentacja — materiały referencyjne i interaktywne demo
    • Listy stronicowane i nieskończone — użyj useInfiniteQuery, aby przy przewijaniu ładować kolejne strony
    • Zapytania czekające na inne — zablokuj kolejne żądanie za pomocą enabled, dopóki warunek wstępny nie zostanie spełniony
    • Optymistyczne interfejsy użytkownika — wyświetl zamierzony wynik przed powrotem odpowiedzi z mutacją

    Pamiętaj o jednej zasadzie: dane zdalne powinny znajdować się w pamięci cache, a nie w stanie komponentu. Mając to na uwadze, reszta API staje się bardziej zrozumiała.