Rate this post

Nawigacja:

Jak działa mechanizm synchronizacji w TanStack Query i dlaczego dane są pobierane ponownie?

Aplikacje webowe tworzone w oparciu o React, Vue czy Svelte niezwykle często zmagają się z problemem zarządzania stanem serwerowym. TanStack Query (dawniej React Query) rozwiązał ten problem poprzez wprowadzenie paradygmatu, w którym stan zewnętrzny nie jest traktowany tak samo jak lokalny stan komponentu. Zamiast tego biblioteka zarządza pamięcią podręczną (cache) jako asynchronicznym odwzorowaniem danych znajdujących się na serwerze. Kluczowym założeniem tej architektury jest zapewnienie spójności interfejsu z rzeczywistym stanem bazy danych, co domyślnie skłania bibliotekę do częstego zweryfikowania, czy posiadane dane są nadal aktualne.

Zjawisko niepotrzebnych lub nadmiarowych zapytań sieciowych (tzw. re-fetchy) rzadko wynika z błędów w samej bibliotece. Jest ono przeważnie rezultatem braku zrozumienia domyślnej konfiguracji oraz cyklu życia zapytań. TanStack Query dąży do spójności danych za wszelką cenę. Jeśli deweloper nie zdefiniuje precyzyjnie granic czasowych oraz zachowań wyzwalaczy (triggers), system uzna każdy powrót użytkownika na stronę lub ponowne zamontowanie komponentu za powód do wysłania żądania HTTP GET.

Stan świeżości (fresh) a nieaktualności (stale) oraz czyszczenie pamięci (gcTime)

Wszystkie zapytania w TanStack Query znajdują się w jednym ze stanów cyklu życia. Dwa najważniejsze z nich to stan świeżości (fresh) oraz stan nieaktualności (stale). Trzecim istotnym pojęciem jest czas usuwania danych z pamięci podręcznej, określany w nowszych wersjach biblioteki jako gcTime (w wersji 4 i wcześniejszych znanym jako cacheTime).

  • Fresh (Świeży): Dane są uznawane za w pełni aktualne. Dopóki zapytanie znajduje się w tym stanie, TanStack Query nie pobierze danych z serwera ponownie, niezależnie od tego, ile razy komponent zostanie ponownie zamontowany lub jak często użytkownik przełączy karty w przeglądarce.
  • Stale (Nieaktualny): Dane nadal znajdują się w pamięci RAM i mogą być błyskawicznie wyświetlone użytkownikowi, ale biblioteka uznaje je za potencjalnie przedawnione. Każda akcja uznawana za wyzwalacz odświeżenia spowoduje wykonanie cichego zapytania w tle (background refetch).
  • Inactive (Nieaktywny): Żaden zamontowany komponent na ekranie nie korzysta w danej chwili z tego konkretnego zapytania. Od tego momentu zaczyna odliczać zegar gcTime. Jeśli czas ten minie, dane zostaną całkowicie usunięte z cache w celu zwolnienia pamięci.

Mit: TanStack Query ignoruje cache, jeśli robi re-fetch. Rzeczywistość: biblioteka wyciąga zcached-owane dane natychmiast i renderuje je na ekranie bez opóźnień, a dopiero w tle wykonuje żądanie sieciowe, by zaktualizować interfejs w sposób niezauważalny dla użytkownika.

Domyślna filozofia stale-while-revalidate

Twórcy TanStack Query przyjęli wzorzec projektowy stale-while-revalidate. Oznacza to, że priorytetem jest natychmiastowe dostarczenie informacji użytkownikowi (nawet jeśli mogą być o kilka sekund nieaktualne), a następnie bezgłośna weryfikacja ich poprawności po stronie serwera. W praktyce oznacza to, że domyślny czas świeżości (staleTime) wynosi dokładnie 0 milisekund.

Oznaczenie danych jako stale natychmiast po ich pobraniu jest świadomą decyzją projektową. Zapewnia ona bezpieczeństwo danych w aplikacjach o wysokiej dynamice (np. systemy transakcyjne, czaty, systemy rezerwacyjne). Jednak w standardowych aplikacjach biznesowych, panelach administracyjnych czy serwisach contentowych, zachowanie to bez dodatkowej konfiguracji prowadzi do zwielokrotnienia ruchu sieciowego i niepotrzebnego obciążenia infrastruktury backendowej.

1. Pozostawienie domyślnego staleTime dla danych, które rzadko się zmieniają

Jak staleTime: 0 wpływa na zachowanie zapytań

Głównym źródłem nadmiarowych zapytań w większości projektów jest poleganie na domyślnej wartości staleTime: 0. Przypadek ten zachodzi wtedy, gdy programista tworzy instancję hooka useQuery bez podania drugiego parametru konfiguracyjnego lub nie definiuje domyślnych opcji w QueryClient. W efekcie każde zapytanie trafia do pamięci podręcznej z etykietą stale w tej samej milisekundzie, w której odpowiedź z serwera dotarła do przeglądarki.

Skutki tego ustawienia są widoczne na każdym kroku nawigacji. Gdy użytkownik przechodzi między widokami aplikacji i wraca do komponentu, który wywołuje to samo zapytanie, TanStack Query widzi dane w pamięci, renderuje je natychmiast, ale z powodu stanu stale generuje kolejne żądanie HTTP. Jeśli na jednym ekranie znajduje się pięć komponentów wywołujących powiązane zapytania z staleTime: 0, przełączenie widoku i powrót spowodują ponowne wysłanie wszystkich pięciu zapytań do API.

Scenariusze, w których wartość domyślna paraliżuje sieć

Nie wszystkie dane w aplikacji zmieniają się z częstotliwością milisekundową. Dobrym przykładem są słowniki systemowe (np. listy krajów, walut, kategorii produktów), ustawienia profilu użytkownika, statyczne konfiguracje widoków czy rola i uprawnienia zalogowanego konta. Pobieranie tych samych danych słownikowych po każdym kliknięciu myszką lub przejściu do sąsiedniej zakładki w interfejsie jest marnowaniem zasobów.

Rozważmy scenariusz, w którym aplikacja e-commerce pobiera drzewo kategorii przy każdym wyrenderowaniu paska nawigacyjnego. Przy domyślnym staleTime: 0 przejście użytkownika przez dziesięć podstron produktów wygeneruje dziesięć identycznych zapytań o drzewo kategorii. Wszystkie odpowiedzi z API zwrócą dokładnie ten sam zestaw danych. Generuje to niepotrzebne koszty transferu (CDN, Load Balancer), zwiększa zużycie procesora na serwerze i przyspiesza drenaż baterii na urządzeniach mobilnych.

Optymalna konfiguracja i dobór czułości dla poszczególnych zasobów

Rozwiązaniem tego błędu jest świadome zarządzanie czasem świeżości na dwóch poziomach: globalnym oraz instancyjnym. Zamiast akceptować zerowy czas świeżości, warto ustawić bezpieczną wartość bazową w konfiguracji globalnej, a dla krytycznych zasobów nadpisywać ją w poszczególnych hookach.

Zbliżenie dłoni programisty piszących kod na laptopie
Źródło: Pexels | Autor: Alicia Christin Gerald
// Konfiguracja globalna w QueryClient
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Ustawienie globalnego czasu świeżości na 5 minut
      staleTime: 1000 * 60 * 5,
    },
  },
});

Dzięki takiej konfiguracji zapytania nie będą automatycznie ponawiane przy każdym montowaniu komponentu, o ile od ostatniego pobrania nie upłynęło 5 minut. W przypadkach szczególnych można dopasować ten czas lokalnie:

// Zapytanie o słownik krajów - dane zmieniane niezwykle rzadko
const { data: countries } = useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: 1000 * 60 * 60 * 24, // 24 godziny
});

// Zapytanie o notowania giełdowe - dane wymagające wysokiej aktualności
const { data: stockPrices } = useQuery({
  queryKey: ['stockPrices'],
  queryFn: fetchStockPrices,
  staleTime: 1000 * 10, // 10 sekund
});

Mit: Dłuższy staleTime zawsze oznacza nieaktualny interfejs. Rzeczywistość: Ustawienie na przykład staleTime: 1000 * 60 * 10 nie blokuje możliwości ręcznego lub wywołanego akcją odświeżenia danych. Zmiana danych po stronie użytkownika (np. edycja formularza) może od razu wywołać unieważnienie cache (invalidation), zmuszając pobieranie do natychmiastowego wykonania niezależnie od upływu czasu.

Typ danychCzęstotliwość zmianDomyślny staleTimeSugerowany staleTimeRekomendowana strategia
Słowniki systemowe / krajeRzadko (miesiące/lata)0 ms24 godziny (Infinity)Pobieranie raz na sesję, ręczna invalidacja tylko przy wdrożeniu.
Profil użytkownika / UstawieniaBardzo rzadko (dni/tygodnie)0 ms15 – 30 minutInvalidacja po zakończeniu edycji profilu (mutacja).
Listy obiektów (np. artykuły, zamówienia)Średnio (minuty/godziny)0 ms1 – 5 minutTrzymanie danych w stanie fresh przez czas nawigacji użytkownika.
Dane w czasie rzeczywistym (np. kursy, czat)Ciągle (sekundy)0 ms0 – 5 sekundStosowanie krótkiego staleTime lub przejście na WebSockets / Server-Sent Events.

2. Bezkrytyczne używanie automatycznych triggerów refetchOnWindowFocus, refetchOnReconnect i refetchOnMount

Mechanizm działania i ukryte koszty automatycznego odświeżania

TanStack Query udostępnia trzy wbudowane wyzwalacze zdarzeniowe, które automatycznie inicjują zapytanie sieciowe w tle, o ile dane zapytanie znajduje się w stanie stale:

  1. refetchOnWindowFocus: Wyzwalany w momencie, gdy okno przeglądarki lub karta z aplikacją uzyskuje ponowny fokus (zdarzenie focus okna).
  2. refetchOnReconnect: Wyzwalany, gdy przeglądarka odzyskuje połączenie sieciowe po jego wcześniejszej utracie (zdarzenie online).
  3. refetchOnMount: Wyzwalany podczas montowania nowej instancji komponentu zawierającego hook useQuery.

Domyślnie wszystkie te trzy flagi są ustawione na wartość true. Połączenie ich z domyślnym staleTime: 0 tworzy bardzo agresywny system odświeżania. Wystarczy, że użytkownik przełączy się na chwilę do komunikatora, wyśle wiadomość i wróci do okna aplikacji – w tym momencie refetchOnWindowFocus wykrywa zdarzenie, sprawdza, że dane są stale (ponieważ staleTime wynosi 0) i natychmiast wysyła zapytania dla wszystkich aktywnych obserwatorów na ekranie.

Kiedy wyłączenie triggerów przynosi realne oszczędności

W środowiskach korporacyjnych, w których użytkownicy pracują wielozadaniowo (

W środowiskach korporacyjnych, w których użytkownicy pracują wielozadaniowo (przełączają karty, odpowiadają na wiadomości, korzystają z kilku systemów jednocześnie), automatyczne odświeżanie przy każdym odzyskaniu fokusu potrafi wygenerować zaskakująco duży ruch. Szczególnie dotyczy to rozbudowanych dashboardów z wieloma aktywnymi zapytaniami: listą zadań, statystykami, powiadomieniami, danymi użytkownika i filtrami. Jedno przejście z komunikatora do aplikacji może wtedy uruchomić serię równoległych żądań, mimo że użytkownik nie wykonał żadnej akcji wymagającej nowych danych.

Mit: wyłączenie refetchOnWindowFocus oznacza rezygnację ze świeżych danych. Rzeczywistość: świeżość można kontrolować precyzyjniej przez sensowny staleTime, invalidację po mutacjach oraz ręczne odświeżenie w miejscach, w których użytkownik faktycznie go oczekuje. Widok ustawień konta nie musi pytać serwera o te same dane po każdym powrocie do karty, podczas gdy ekran monitorujący aktywne zamówienia może zachować automatyczne odświeżanie.

// Globalna konfiguracja dla aplikacji biznesowej
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      refetchOnWindowFocus: false,
      refetchOnReconnect: true,
      refetchOnMount: false,
    },
  },
});

// Widok wymagający szybkiej aktualizacji może nadpisać ustawienia
const { data: orders } = useQuery({
  queryKey: ['orders', 'active'],
  queryFn: fetchActiveOrders,
  staleTime: 1000 * 30,
  refetchOnWindowFocus: true,
});

Nie trzeba jednak wyłączać wszystkiego globalnie bez analizy. refetchOnReconnect często pozostaje rozsądnym zabezpieczeniem, ponieważ użytkownik po powrocie z trybu offline może oczekiwać aktualnych danych. Podobnie refetchOnMount bywa przydatne dla ekranów operacyjnych, ale tylko wtedy, gdy zapytanie jest już nieświeże. Mit: każda opcja ustawiona na false jest optymalizacją. Rzeczywistość: optymalizacja polega na dopasowaniu triggera do charakteru zasobu, a nie na mechanicznym wyłączaniu mechanizmów biblioteki.

Najmniej re-fetchy daje nie „najbardziej agresywny” cache, lecz cache zgodny z rzeczywistym cyklem życia danych. Czas świeżości, automatyczne triggery, poprawnie zaprojektowane klucze zapytań i trwała instancja QueryClient powinny tworzyć jedną strategię, a nie zbiór przypadkowych ustawień.

3. Budowanie niestabilnych albo zbyt ogólnych queryKey

Wpływ architektury kluczy na poprawną identyfikację cache

Pamięć podręczna w TanStack Query działa w oparciu o unikalne klucze (queryKey), które są tablicami zawierającymi dowolne dane podatne na serializację. Biblioteka porównuje klucze przy użyciu algorytmu deterministycznego hashowania. Jeżeli dwa komponenty wywołują hooki z tak samo wyglądającą tablicą klucza, TanStack Query uznaje, że proszą o ten sam zasób i udostępnia im jedną instancję danych z pamięci podręcznej.

Błąd w projektowaniu kluczy objawia się na dwa sposoby: poprzez budowanie kluczy niestabilnych (zmieniających wartość przy każdym renderze) oraz kluczy zbyt ogólnych (łączących odrębne zasoby pod jednym adresem).

Niestabilne referencje i wycieki zmiennych dynamicznych

Jeśli wewnątrz queryKey umieścisz obiekt, którego referencja tworzona jest wprost w ciele komponentu bez memoizacji, albo wartość uzależnioną od czasu (np. Date.now() czy nową instancję obiektu konfiguracji), biblioteka przy każdym wyrenderowaniu komponentu potraktuje zapytanie jako fabrycznie nowe.

// BŁĄD: Nowy obiekt referencyjny przy każdym renderze komponentu
function UserProfile({ userId }) {
  const { data } = useQuery({
    // Obiekt { id: userId } tworzy nową referencję w pamięci RAM przy każdym renderze!
    queryKey: ['user', { id: userId, timestamp: Date.now() }],
    queryFn: () => fetchUser(userId),
  });
}

W wyżej opisanym przypadku każde wyrenderowanie rodzica powoduje wygenerowanie nowego klucza. TanStack Query szuka w cache klucza z dokładnym znacznikiem czasu, nie znajduje go, uznaje zapytanie za całkowicie nieznane i pobiera dane od nowa, pomimo że profil użytkownika nie uległ żadnej zmianie.

Konstruowanie spójnych kluczy za pomocą fabryk (Query Key Factories)

Aby zapobiec chaotycznemu tworzeniu kluczy w różnych miejscach aplikacji, warto wdrożyć wzorzec fabryki kluczy. Zapewnia on jednoznaczność, pełną kontrolę nad typami oraz eliminację przypadkowych zmian struktury.

// Dobre praktyki: Centralna fabryka kluczy dla zasobu użytkowników
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

// Użycie w komponencie - klucz jest jednoznaczny i stabilny
const { data: user } = useQuery({
  queryKey: userKeys.detail(userId),
  queryFn: () => fetchUser(userId),
});

Używanie spójnej hierarchii kluczy pozwala także na bardzo precyzyjne odświeżanie danych: unieważnienie userKeys.all unieważni wszystkie listy i detale, podczas gdy unieważnienie userKeys.detail('123') dotknie wyłącznie jednego, konkretnego użytkownika.

4. Tworzenie nowego QueryClient przy renderowaniu aplikacji

Dlaczego instancja QueryClient musi posiadać trwały cykl życia

QueryClient jest sercem i głównym magazynem stanu TanStack Query. To w nim przechowywana jest cała struktura pamięci podręcznej, informacje o statusach zapytań, subskrypcjach komponentów oraz timerach sterujących stanami stale i gcTime. Tworzenie nowej instancji klienta powoduje natychmiastowe usunięcie całego dotychczasowego cache i zastąpienie go pustym magazynem.

Zbliżenie na kobietę programującą na laptopie w biurze
Źródło: Pexels | Autor: MART PRODUCTION

Powszechnym błędem architektonicznym, szczególnie w aplikacjach z renderowaniem po stronie serwera (SSR) lub z nieprawidłowo zorganizowaną strukturą komponentu korzenia (Root), jest inicjalizacja klienta wewnątrz ciała funkcji komponentu.

// BŁĄD: Inicjalizacja instancji w ciele komponentu React
function App() {
  // Przebudowa komponentu App (np. zmiana stanu, kontekstu)
  // tworzy nową instancję QueryClient i czyści cały cache!
  const queryClient = new QueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <MainLayout />
    </QueryClientProvider>
  );
}

Skutki zerowania pamięci podręcznej przy re-renderze rodzica

Gdy komponent App zostanie wyrenderowany ponownie (np. z powodu zmiany motywu, zmiany stanu autoryzacji czy aktualizacji globalnego kontekstu), instrukcja new QueryClient() wykona się po raz kolejny. Stary magazyn cache zostanie porzucony w pamięci i poddany czyszczeniu przez Garbage Collector.

Wszystkie aktywne hooki useQuery w drzewie komponentów połączą się z nową, pustą instancją. Ponieważ w nowej pamięci podręcznej nie ma żadnych danych, każde zapytanie na ekranie natychmiast przejdzie w stan ładowania i wyśle ponowne żądania sieciowe do serwera. Tworzy to efekt zapętlenia i całkowicie niweczy sens stosowania pamięci podręcznej.

Prawidłowa dekoracja instancji dla aplikacji SPA i frameworków SSR

W aplikacjach typu Single Page Application (SPA) instancja powinna zostać utworzona raz, poza cyklem życia komponentów Reacta:

// PRAWIDŁOWO (SPA): Instancja stworzona w zasięgu modułu
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
    },
  },
});

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MainLayout />
    </QueryClientProvider>
  );
}

W przypadku frameworków SSR (np. Next.js, Remix), gdzie stan nie może być współdzielony między różnymi zapytaniami użytkowników na serwerze, instancję należy zachować za pomocą leniwej inicjalizacji stanu lub Referencji:

// PRAWIDŁOWO (SSR / Next.js App Router): Zachowanie instancji w stanie
'use client';

import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export default function Providers({ children }: { children: React.ReactNode }) {
  // useState z funkcją inicjalizacyjną gwarantuje utworzenie klienta tylko raz
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

5. Nieprecyzyjne unieważnianie pamięci podręcznej (invalidateQueries) po mutacji

Jak działają wzorce dopasowywania kluczy podczas unieważniania

Po wykonaniu operacji zapisu danych (mutacji) naturalnym krokiem jest odświeżenie widoku. Wykorzystuje się do tego metodę queryClient.invalidateQueries(). Domyślnie mechanizm dopasowywania kluczy w TanStack Query działa w oparciu o tzw. dopasowywanie rozmyte (fuzzy matching). Oznacza to, że unieważnienie klucza bazowego unieważnia również wszystkie klucze podrzędne.

Błąd polega na wywoływaniu tej metody z kluczem zbyt wysokiego poziomu lub bez wskazania dodatkowych opcji filtrujących. Wywołanie invalidateQueries({ queryKey: ['users'] }) unieważni nie tylko listę użytkowników, ale też szczegóły każdego pobranego profilu, ich statystyki oraz powiązane zapytania filtrujące.

Lawinowe odświeżanie zapytań nieaktywnych i aktywnych

Gdy metoda invalidateQueries unieważni szeroką grupę zapytań, wszystkie aktywne zapytania (widoczne na ekranie) natychmiast wysyłają równoległe żądania do API, aby odświeżyć dane w tle. Zapytania nieaktywne (znajdujące się w cache, ale niewyświetlane w danym momencie) zostają oznaczone jako stale i zostaną ponownie pobrane od razu po wejściu na dany ekran.

Zamiast wykonywać agresywne, powszechne unieważnienia, należy ograniczyć zakres do kluczy o wysokiej precyzji lub zastosować opcję exact: true.

// BŁĄD: Unieważnienie całego drzewa danych użytkownika po edycji jednego pola
const mutation = useMutation({
  mutationFn: updateUserName,
  onSuccess: () => {
    // Odświeża wszystkie zapytania zaczynające się od 'users'
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

// PRAWIDŁOWO: Precyzyjne unieważnienie lub bezpośrednia aktualizacja cache
const mutation = useMutation({
  mutationFn: updateUserName,
  onSuccess: (updatedUser) => {
    // 1. Unieważnienie wyłącznie konkretnej listy z zachowaniem dokładnego matchingu
    queryClient.invalidateQueries({ 
      queryKey: userKeys.lists(), 
      exact: true 
    });

    // 2. Bezpośrednia aktualizacja danych w cache dla widoku szczegółów bez wysyłania zapytania HTTP
    queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser);
  },
});

Użycie metody setQueryData pozwala na wstawienie zaktualizowanej odpowiedzi bezpośrednio z rezultatu mutacji do pamięci podręcznej. Dzięki temu aplikacja aktualizuje interfejs użytkownika natychmiastowo, z zerowym opóźnieniem i bez generowania ani jednego dodatkowego żądania sieciowego HTTP.

Checklista: Jak wyeliminować niepotrzebne re-fetchy w Twoim projekcie

  • Skonfiguruj globalny staleTime: Zastąp domyślne staleTime: 0 bezpieczną wartością bazową (np. 1-5 minut) w opcjach QueryClient.
  • Przejrzyj automatyczne triggery: Wyłącz refetchOnWindowFocus oraz refetchOnMount globalnie, jeśli tworzysz aplikację z rzadko zmieniającymi się danymi, a wyzwalacze włączaj tylko tam, gdzie świeżość danych w czasie rzeczywistym jest kluczowa.
  • Zadbaj o stabilność queryKey: Stosuj fabryki kluczy (Query Key Factories), unikaj przekazywania niestabilnych obiektów oraz zmiennych dynamicznych (np. Date.now()) bezpośrednio do tablicy klucza.
  • Zabezpiecz instancję QueryClient: Upewnij się, że new QueryClient() nie wykonuje się w ciele komponentu React podczas jego re-renderowania (użyj inicjalizacji poza komponentem lub wewnątrz useState).
  • Precyzyjnie unieważniaj cache po mutacjach: Unikaj ogólnych kluczy w invalidateQueries. Stosuj flaga exact: true lub aktualizuj pamięć podręczną bezpośrednio przez setQueryData.

3. Budowanie niestabilnych albo zbyt ogólnych queryKey

Wpływ dynamicznych wartości i obiektów na dopasowanie klucza w pamięci

Pamięć podręczna TanStack Query działa w oparciu do ścisłe porównywanie tablicy queryKey za pomocą algorytmu głębokiej serializacji (deterministic serialization). Oznacza to, że kolejność elementów w tablicy, ich typy oraz unikalność mają bezpośrednie przełożenie na to, czy zapytanie trafi w istniejący wpis w pamięci, czy wywoła ponowne pobranie danych z serwera.

Kod programistyczny wyświetlony na ekranie laptopa
Źródło: Pexels | Autor: Negative Space

Częstym błędem jest przekazywanie do tablicy klucza obiektów inline, niestabilnych zmiennych lub wartości zmieniających się przy każdym wyrenderowaniu komponentu, takich jak nowe instancje dat, wywołania Date.now() czy

losowe identyfikatory. Każda taka wartość zmienia wynik serializacji, więc dla biblioteki oznacza inne zapytanie i osobny wpis w cache.

// BŁĄD: timestamp zmienia queryKey przy każdym renderze
const { data } = useQuery({
  queryKey: ['reports', { teamId, generatedAt: Date.now() }],
  queryFn: () => fetchReports(teamId),
});

W tym przykładzie zapytanie nie ma szans ponownie użyć wcześniejszej odpowiedzi. Nawet jeśli teamId pozostaje bez zmian, nowy timestamp powoduje utworzenie nowego klucza, a więc także nowe pobranie.

Popularny mit mówi, że każdy obiekt zapisany inline w queryKey jest automatycznie niestabilny. Rzeczywistość jest bardziej precyzyjna: TanStack Query potrafi deterministycznie hashować zwykłe, serializowalne obiekty. Dwa obiekty z tymi samymi parami klucz–wartość będą traktowane tak samo, nawet jeśli powstały podczas osobnych renderów. Problem zaczyna się wtedy, gdy obiekt zawiera zmienne dane, funkcje, klasy, daty tworzone na bieżąco albo parametry, które nie powinny wpływać na wynik zapytania.

Klucz powinien opisywać dokładnie to, od czego zależy odpowiedź API. Jeśli endpoint pobiera listę z uwzględnieniem strony, sortowania i filtra, te parametry powinny znaleźć się w queryKey. Nie należy natomiast dodawać do niego stanu interfejsu, który nie zmienia danych zwracanych przez serwer, na przykład informacji o otwartym modalu.

// DOBRZE: klucz zawiera tylko parametry wpływające na odpowiedź
const { data } = useQuery({
  queryKey: ['products', { page, sort, categoryId }],
  queryFn: () => fetchProducts({ page, sort, categoryId }),
});

// NIEPOTRZEBNIE: openFilters nie wpływa na wynik API
const { data } = useQuery({
  queryKey: ['products', { page, sort, categoryId, openFilters }],
  queryFn: () => fetchProducts({ page, sort, categoryId }),
});

Zbyt ogólny klucz prowadzi do kolizji danych

Drugą stroną problemu są klucze zbyt mało szczegółowe. Jeśli różne warianty zapytania używają tego samego queryKey, cache nie rozróżni ich odpowiedzi. Efektem może być pokazanie danych z poprzedniego filtra, błędne odświeżenie albo konieczność agresywnego unieważniania całego wpisu.

// BŁĄD: każdy filtr korzysta z tego samego wpisu cache
useQuery({
  queryKey: ['orders'],
  queryFn: () => fetchOrders({ status, page }),
});

// PRAWIDŁOWO: parametry odpowiedzi są częścią klucza
useQuery({
  queryKey: ['orders', { status, page }],
  queryFn: () => fetchOrders({ status, page }),
});

Mit: im krótszy queryKey, tym lepiej. W rzeczywistości krótki klucz jest dobry tylko wtedy, gdy jednoznacznie identyfikuje zasób. Klucz dla listy zamówień bez informacji o filtrach nie jest prostszy — jest niekompletny.

Fabryki kluczy ograniczają przypadkowe rozbieżności

Przy większej liczbie ekranów dobrze sprawdza się jedna definicja kluczy dla danego zasobu. Dzięki temu komponent listy, widok szczegółów i mutacja odwołują się do identycznej struktury, a unieważnianie nie opiera się na ręcznie wpisywanych tablicach w wielu plikach.

const orderKeys = {
  all: ['orders'] as const,
  lists: () => [...orderKeys.all, 'list'] as const,
  list: (filters: { status?: string; page: number }) =>
    [...orderKeys.lists(), filters] as const,
  details: () => [...orderKeys.all, 'detail'] as const,
  detail: (id: string) => [...orderKeys.details(), id] as const,
};

useQuery({
  queryKey: orderKeys.list({ status, page }),
  queryFn: () => fetchOrders({ status, page }),
});

Taka konwencja nie zastępuje przemyślanej konfiguracji świeżości danych, ale eliminuje sporą klasę pomyłek: literówki w nazwach zasobów, pominięte parametry oraz przypadkowe unieważnienie niewłaściwego wpisu.

Kontrola zachowania zapytań zamiast wyłączania cache

Niepotrzebny re-fetch rzadko wynika z jednego ustawienia. Najczęściej jest skutkiem połączenia kilku mechanizmów: dane od razu stają się nieświeże, odzyskanie fokusu uruchamia trigger, a komponent trafia do nowego lub błędnie opisanego wpisu cache. Dlatego diagnozę najlepiej zaczynać od sprawdzenia konkretnego klucza, czasu staleTime oraz powodu, dla którego zapytanie zostało uruchomione.

TanStack Query nie pobiera danych ponownie „bez powodu” — reaguje na reguły skonfigurowane przez aplikację. Dobrze dobrane klucze, trwała instancja QueryClient, rozsądny czas świeżości i precyzyjne unieważnienia pozwalają zachować aktualny interfejs bez zalewania API identycznymi żądaniami.