Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Udostępnij Udostępnij Kontakt
Wprowadzenie

W poprzednim wpisie nauczyliśmy się komunikować z API używając Axios i Fetch. Ale zauważyłeś pewnie, że za każdym razem musimy ręcznie zarządzać stanem loading (ładowanie), error (błąd), data (dane), refetch (ponowne pobieranie), cache (pamięć podręczna – zapisane wcześniej pobrane dane). To dużo boilerplate (powtarzalnego kodu) i łatwo o błędy.

🔍 Problem z tradycyjnym fetching:

Bez React Query, każdy API call to ~20 linii kodu:

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await axios.get('/users');
        setUsers(response.data);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    }
    fetchUsers();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <ul>{users.map(...)}</ul>;
}

Problemy:

  • ❌ Dużo boilerplate (20 linii za każdym razem!)
  • ❌ Brak cache - każde odwiedzenie komponentu = nowy request
  • ❌ Brak retry - błąd = koniec
  • ❌ Brak refetch - stare dane zostają
  • ❌ Trudne testowanie

React Query (obecnie nazywany TanStack Query) rozwiązuje wszystkie te problemy. To biblioteka, która automatycznie zarządza stanem serwerowym – danymi pobieranymi z API. Daje cache, automatyczne refetch, synchronizację między tabami przeglądarki, optimistic updates (optymistyczne aktualizacje – zaktualizuj UI przed otrzymaniem odpowiedzi z serwera, zakładając że operacja się powiedzie) i wiele więcej. To zmienia sposób myślenia o danych w React.

🔍 Co to jest "stan serwerowy" vs "stan klienta"?

Stan klienta = dane które żyją TYLKO w przeglądarce:

  • Czy modal jest otwarty? (true/false)
  • Aktualny tab w nawigacji
  • Wartości w formularzu (przed wysłaniem)

Stan serwerowy = dane które pochodzą z API:

  • Lista użytkowników z bazy danych
  • Dane profilu użytkownika
  • Produkty w sklepie

React Query zarządza TYLKO stanem serwerowym! Dla stanu klienta używaj useState/Zustand. 🎯

W tym wpisie poznamy podstawy React Query – queries (zapytania do API), mutations (operacje zmieniające dane), cache, refetch strategies (strategie odświeżania danych). To będzie praktyczny wpis z wieloma wyjaśnieniami – po nim nie będziesz już ręcznie zarządzał stanem API calls!

Instalacja

npm install @tanstack/react-query
npm install @tanstack/react-query-devtools
🔍 Co instalujemy?
  • @tanstack/react-query - główna biblioteka (hooks: useQuery, useMutation)
  • @tanstack/react-query-devtools - panel deweloperski (widać cache, queries, refetch)

DevTools to super narzędzie - zobaczysz wszystkie queries, ich status, cache, możesz ręcznie refetch! 🛠️

Setup

🔍 Setup krok po kroku:
  1. Stwórz QueryClient - "mózg" React Query (trzyma cache, config)
  2. Obuduj app w QueryClientProvider - udostępnia QueryClient wszystkim komponentom
  3. Dodaj ReactQueryDevtools - panel do debugowania (opcjonalne, ale polecam!)
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Utwórz QueryClient - konfiguracja React Query dla całej aplikacji
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 60 sekund - jak długo dane są uważane za "świeże"
      retry: 1, // Liczba ponownych prób przy błędzie
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
            <React.StrictMode>
            <QueryClientProvider client={queryClient}>
            <App />
      {/* DevTools - panel do debugowania, widoczny tylko w development */}
            <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);

QueryClientProvider opakowuje całą aplikację i dostarcza dostęp do React Query we wszystkich komponentach.

🔍 Co to jest staleTime?

staleTime = jak długo dane są "świeże" (fresh)?

  • Fresh (świeże) - dane są aktualne, NIE refetch automatycznie
  • Stale (nieświeże) - dane mogą być przestarzałe, refetch w tle

Przykład z staleTime: 60000 (60 sekund):

// t=0s: Pierwszy render komponentu
→ Fetch z API (brak cache)
→ Dane są FRESH przez 60s

// t=10s: Drugi render tego samego komponentu  
→ NIE fetchuje! Dane są fresh (minęło 10s < 60s)
→ Pokazuje z cache ⚡

// t=70s: Trzeci render
→ Dane są STALE (minęło 70s > 60s)
→ Pokazuje stare z cache, ale refetch w tle
→ Jak dostanie nowe dane, aktualizuje UI

Krótki staleTime (5s) = częste refetch (zawsze aktualne dane, więcej requestów)
Długi staleTime (5min) = rzadkie refetch (może być nieaktualne, mniej requestów)

🌟 Przykład: Różne staleTime dla różnych danych
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // Domyślnie 5 minut
    },
  },
});

// W komponencie możesz nadpisać:

// Dane które się rzadko zmieniają (lista krajów)
useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: 24 * 60 * 60 * 1000, // 24 godziny! Kraje się nie zmieniają
});

// Dane które się często zmieniają (cena akcji)
useQuery({
  queryKey: ['stock-price'],
  queryFn: fetchStockPrice,
  staleTime: 10 * 1000, // 10 sekund - zawsze aktualne!
});

// Dane które NIE mogą być stale (koszyk użytkownika)
useQuery({
  queryKey: ['cart'],
  queryFn: fetchCart,
  staleTime: 0, // Zawsze stale = zawsze refetch!
});
useQuery - pobieranie danych

useQuery to hook do pobierania danych z API (operacje read-only, tylko czytanie).

Podstawowe użycie

🔍 useQuery vs tradycyjny fetch - porównanie:
❌ BEZ React Query (20 linii)
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  async function fetch() {
    try {
      setLoading(true);
      const res = await axios.get('/users');
      setData(res.data);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }
  fetch();
}, []);
✅ Z React Query (4 linie!)
const { data, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: () => axios.get('/users')
});

Bonus z React Query: cache, retry, refetch, synchronizacja między tabami - za darmo! 🎁

import { useQuery } from '@tanstack/react-query';
import { apiClient } from './api/client';

interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  // useQuery automatycznie zarządza loading, error, data
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'], // Unikalny klucz dla tego query
    queryFn: async () => {
      const response = await apiClient.get('/users');
      return response.data;
    },
  });

  if (isLoading) return <div>Ładowanie...</div>;
  if (error) return <div>Błąd: {error.message}</div>;

  return (
            <ul>
      {data?.map(user => (
            <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Co tu się dzieje?

  • queryKey – unikalny identyfikator query (używany do cache)
  • queryFn – funkcja asynchroniczna pobierająca dane
  • React Query automatycznie zarządza loading, error, data
  • Cache – dane są zapisywane, przy kolejnym renderze zwraca cache zamiast pytać API
🔍 Lifecycle useQuery - co się dzieje pod maską:
  1. Pierwsze wywołanie:
    • isLoading = true
    • Wywołuje queryFn (fetch z API)
    • Zapisuje wynik w cache pod kluczem ['users']
    • isLoading = false, data = wynik
  2. Drugie wywołanie (w tym samym komponencie lub innym):
    • Sprawdza cache dla klucza ['users']
    • Znalazł! data = cache (natychmiast! ⚡)
    • Sprawdza: czy dane są stale?
    • Jeśli stale → refetch w tle
    • Jak dostanie nowe dane → aktualizuje

Query Key - klucz zapytania

Query key to unikalny identyfikator query. Może być stringiem lub tablicą.

🔍 Po co Query Key?

Query Key = unikalny ID dla danych w cache. React Query używa go żeby:

  • ✅ Zapisać dane w cache (key = adres w pamięci)
  • ✅ Znaleźć dane w cache (szuka po key)
  • ✅ Invalidować cache (usuń dane o tym key)
  • ✅ Refetch konkretne dane (odśwież tylko ten key)

Reguła: Różne dane = różne keys!

// Prosty klucz
useQuery({ queryKey: ['users'], queryFn: fetchUsers });

// Klucz z parametrami (dla różnych użytkowników)
useQuery({ 
  queryKey: ['user', userId], // ['user', 1], ['user', 2], etc.
  queryFn: () => fetchUser(userId) 
});

// Klucz z wieloma parametrami
useQuery({ 
  queryKey: ['posts', { status: 'published', page: 1 }],
  queryFn: () => fetchPosts({ status: 'published', page: 1 })
});
🔍 Dlaczego klucz to tablica, a nie string?

Tablica pozwala dodawać parametry! Każda kombinacja = osobny cache:

// 3 różne query keys = 3 osobne cache
['user', 1]  →  cache dla użytkownika 1
['user', 2]  →  cache dla użytkownika 2  
['user', 5]  →  cache dla użytkownika 5

// Każdy ma swoje dane w cache!
// Gdy zmienisz userId z 1 na 2:
// → React Query widzi nowy key ['user', 2]
// → Sprawdza cache
// → Nie ma? Fetch z API
// → Ma? Pokaż z cache ⚡
🌟 Przykład: Query Keys w praktyce
// ✅ DOBRY - parametry w query key
function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user', userId],  // Każdy user ma osobny cache!
    queryFn: () => fetchUser(userId)
  });
}

// Scenariusz:
// 1. userId = 1 → fetch user 1 → cache jako ['user', 1]
// 2. userId = 2 → fetch user 2 → cache jako ['user', 2]
// 3. userId = 1 → pokazuje z cache! (już był!) ⚡

// ❌ ZŁY - parametr NIE w query key
function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user'],  // Zawsze ten sam key!
    queryFn: () => fetchUser(userId)
  });
}

// Scenariusz:
// 1. userId = 1 → fetch user 1 → cache jako ['user']
// 2. userId = 2 → React Query widzi ten sam key ['user']
// 3. Myśli że ma dane! Pokazuje user 1 zamiast user 2! 💥
// BUG!
💪 Ćwiczenie 1: Użyj useQuery

Stwórz komponent PostsList który:

  1. Pobiera posty z /api/posts
  2. Używa useQuery z kluczem ['posts']
  3. Pokazuje loading podczas ładowania
  4. Pokazuje błąd jeśli fetch failuje
  5. Wyświetla listę postów (title, body)

Bonus: Dodaj staleTime: 2 * 60 * 1000 (2 minuty) - posty rzadko się zmieniają!

Status i flags (flagi statusu)

🔍 isLoading vs isFetching - jaka różnica?

To najczęstsza pułapka dla początkujących!

isLoading = true TYLKO gdy:

  • Pierwsze ładowanie (brak cache)
  • Loading spinner = isLoading

isFetching = true gdy:

  • Pierwsze ładowanie (jak isLoading)
  • + Refetch w tle (masz cache, ale pobiera nowe dane)
  • Background refresh indicator = isFetching
// Scenariusz:
// 1. Pierwsze otwarcie komponentu
isLoading = true   // Brak cache, pokazujemy spinner
isFetching = true

// 2. Dostaliśmy dane, zapisaliśmy w cache
isLoading = false  // Mamy dane! 
isFetching = false

// 3. Użytkownik wrócił do zakładki (refetchOnWindowFocus)
isLoading = false  // Mamy cache! Pokazujemy dane
isFetching = true  // Ale refetch w tle (małe loading w rogu)

// 4. Dostaliśmy nowe dane
isLoading = false
isFetching = false
const { 
  data,           // Dane z API lub cache
  error,          // Obiekt błędu (jeśli wystąpił)
  
  // Status
  status,         // 'pending' | 'error' | 'success'
  
  // Boolean flags (flagi logiczne) - wygodniejsze od status
  isLoading,      // Pierwsze ładowanie (brak cache)
  isFetching,     // Pobieranie (nawet jeśli jest cache)
  isError,        // Czy wystąpił błąd
  isSuccess,      // Czy sukces
  
  // Dodatkowe
  isPending,      // === isLoading
  refetch,        // Funkcja do ręcznego refetch
  fetchStatus,    // 'fetching' | 'paused' | 'idle'
} = useQuery({ queryKey: ['users'], queryFn: fetchUsers });

Różnica isLoading vs isFetching:

  • isLoading – true tylko przy pierwszym ładowaniu (brak cache)
  • isFetching – true zawsze gdy pobiera dane (nawet z cache)
🌟 Przykład: Użycie isLoading vs isFetching w UI
function UserList() {
  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  return (
    <div>
      {/* isLoading - pełny loading screen (brak cache) */}
      {isLoading && (
        <div className="full-page-spinner">
          <Spinner />
          <p>Ładowanie użytkowników...</p>
        </div>
      )}

      {/* isFetching - małe loading (mamy cache, refetch w tle) */}
      {isFetching && !isLoading && (
        <div className="background-refresh">
          <small>Odświeżanie...</small>
        </div>
      )}

      {/* Dane (pokazujemy nawet podczas isFetching!) */}
      {data && (
        <ul>
          {data.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

// UX:
// 1. Pierwsze ładowanie → pełny spinner (isLoading)
// 2. Mamy dane → lista użytkowników
// 3. Refetch → lista + małe "odświeżanie..." (isFetching)

TypeScript w useQuery

interface User {
  id: number;
  name: string;
  email: string;
}

// Automatyczne typowanie
const { data } = useQuery({
  queryKey: ['users'],
  queryFn: async (): Promise<User[]> => {
    const response = await apiClient.get<User[]>('/users');
    return response.data;
  },
});
// data ma typ: User[] | undefined

// Lub z generic
const { data } = useQuery<User[]>({
  queryKey: ['users'],
  queryFn: async () => {
    const response = await apiClient.get<User[]>('/users');
    return response.data;
  },
});
Query Options - opcje konfiguracyjne

staleTime - czas "świeżości" danych

staleTime określa jak długo dane są uważane za świeże (fresh). Świeże dane nie są ponownie pobierane.

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5 minut
});
  • 0 (default) – dane stale się natychmiast po pobraniu (zawsze refetch)
  • Infinity – dane nigdy nie stale się (nigdy nie refetch automatycznie)

cacheTime (od v5: gcTime) - czas życia cache

gcTime (garbage collection time) określa jak długo cache jest przechowywany po odmontowaniu komponentu.

🔍 staleTime vs gcTime - co jest czym?

staleTime = kiedy dane są "nieświeże" (kiedy refetch)
gcTime = kiedy usunąć cache (po odmontowaniu)

// Scenariusz z staleTime: 60s, gcTime: 5min

// t=0s: Komponent montuje się
→ Fetch z API
→ Cache: { users: [...data] }

// t=30s: Komponent montuje się ponownie
→ Dane fresh (30s < 60s staleTime)
→ Pokazuje z cache, NIE refetch

// t=90s: Komponent montuje się ponownie
→ Dane stale (90s > 60s staleTime)
→ Pokazuje z cache, ALE refetch w tle

// t=100s: Komponent się odmontowuje
→ Cache ZOSTAJE przez 5min (gcTime)

// t=200s: Komponent montuje się ponownie
→ Cache NADAL istnieje! (200s < 5min gcTime)
→ Pokazuje cache, refetch w tle

// t=400s: (5min od odmontowania)
→ Cache usunięty (garbage collected)
→ Następne montowanie = fetch z API

Reguła kciuka: gcTime > staleTime (żeby cache przetrwało dłużej niż świeżość)

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  gcTime: 10 * 60 * 1000, // 10 minut (default: 5 minut)
});

Po odmontowaniu komponentu, cache jest przechowywany przez gcTime, potem usuwany.

refetchOnWindowFocus - odświeżanie przy fokusie

Automatycznie odśwież dane gdy użytkownik wraca do karty przeglądarki.

🔍 Po co refetchOnWindowFocus?

Scenariusz: Użytkownik patrzy na listę użytkowników, zmienia zakładkę (sprawdza email), wraca po 5 minutach.

  • ❌ Bez refetchOnWindowFocus: Widzi starą listę (ktoś mógł się zarejestrować w międzyczasie!)
  • ✅ Z refetchOnWindowFocus: Automatyczny refetch! Zawsze aktualne dane! 🔄

Super UX! Użytkownik nawet nie wie że dane się odświeżyły. Po prostu zawsze są aktualne! 🎯

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  refetchOnWindowFocus: true, // default: true
});

refetchInterval - automatyczne odświeżanie

Odświeżaj dane co X milisekund.

🔍 Kiedy używać refetchInterval?

Dane real-time:

  • ✅ Ceny akcji (co 5s)
  • ✅ Status zamówienia (co 30s)
  • ✅ Live chat (co 2s)
  • ✅ Dashboard z live metrics

Uwaga: To NIE jest WebSocket! To polling (co X sekund pytasz API). Dla prawdziwego real-time użyj WebSocket! 📡

// Odświeżaj co 10 sekund
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  refetchInterval: 10000,
});

// Odświeżaj tylko gdy widoczny
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  refetchInterval: 10000,
  refetchIntervalInBackground: false, // default: false
});

enabled - warunkowe włączanie query

Wykonaj query tylko gdy warunek spełniony.

🔍 Po co enabled?

Problem bez enabled:

function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)  // Jeśli userId = undefined → błąd!
  });
}

Rozwiązanie z enabled:

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  enabled: !!userId  // ✅ Fetch TYLKO gdy userId istnieje!
});
function UserProfile({ userId }: { userId?: number }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId, // Wykonaj tylko gdy userId istnieje
  });

  if (!userId) return <div>Wybierz użytkownika</div>;
  if (!user) return <div>Ładowanie...</div>;

  return <div>{user.name}</div>;
}

retry - ponowne próby przy błędzie

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  retry: 3, // 3 próby (default: 3)
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  // Wykładniczy delay: 1s, 2s, 4s, 8s...
});
💪 Ćwiczenie 2: Query Options w praktyce

Stwórz komponent StockPrice który wyświetla cenę akcji:

  1. Pobiera cenę z /api/stock/AAPL
  2. staleTime: 10s (ceny się często zmieniają!)
  3. refetchInterval: 5000 (odświeżaj co 5s)
  4. refetchOnWindowFocus: true
  5. Pokazuje cenę i znacznik "odświeżanie..." gdy isFetching
Dependent Queries - zapytania zależne

Wykonaj drugie query dopiero gdy pierwsze się zakończy.

🔍 Dependent Queries - kiedy używać?

Scenariusz: Musisz pobrać dane B, ale potrzebujesz ID z danych A.

  1. Pobierz użytkownika → dostaniesz user.companyId
  2. Pobierz firmę używając companyId

Bez enabled: Próbujesz fetch company z undefined ID → błąd! 💥
Z enabled: Czekasz na user, potem fetch company → działa! ✅

function UserPosts({ userId }: { userId: number }) {
  // Pierwsze query - pobierz użytkownika
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Drugie query - pobierz posty użytkownika
  // Wykonaj TYLKO gdy mamy user
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchUserPosts(user!.id),
    enabled: !!user, // Włącz tylko gdy user istnieje
  });

  return (
    <div>
      <h2>{user?.name}</h2>
      <ul>
        {posts?.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}
🌟 Przykład: 3-poziomowa zależność
function TeamDetails({ userId }) {
  // 1. Pobierz użytkownika
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // 2. Pobierz zespół użytkownika
  const { data: team } = useQuery({
    queryKey: ['team', user?.teamId],
    queryFn: () => fetchTeam(user!.teamId),
    enabled: !!user?.teamId,
  });

  // 3. Pobierz projekty zespołu
  const { data: projects } = useQuery({
    queryKey: ['projects', team?.id],
    queryFn: () => fetchTeamProjects(team!.id),
    enabled: !!team?.id,
  });

  return (
    <div>
      <h1>{user?.name}</h1>
      <h2>Zespół: {team?.name}</h2>
      <h3>Projekty:</h3>
      <ul>
        {projects?.map(p => <li>{p.name}</li>)}
      </ul>
    </div>
  );
}

// Flow:
// user fetch → team fetch → projects fetch
// Każdy czeka na poprzedni! 🔗
Parallel Queries - równoległe zapytania

Wykonaj wiele queries jednocześnie.

🔍 Parallel vs Dependent - różnica:

Parallel (równoległe):

  • Queries są niezależne (nie potrzebują danych od siebie)
  • Wykonują się JEDNOCZEŚNIE
  • Szybciej! (3 requesty w 1s zamiast 3s)

Dependent (zależne):

  • Queries są zależne (B potrzebuje ID z A)
  • Wykonują się KOLEJNO
  • Wolniej, ale konieczne!
function Dashboard() {
  const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const postsQuery = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const statsQuery = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
  });

  // Wszystkie wykonują się równolegle!

  if (usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
    return <div>Ładowanie...</div>;
  }

  return (
    <div>
      <UsersList users={usersQuery.data} />
      <PostsList posts={postsQuery.data} />
      <Stats data={statsQuery.data} />
    </div>
  );
}

useQueries - dynamiczna liczba queries

Gdy liczba queries jest dynamiczna (z tablicy).

🔍 Kiedy używać useQueries?

Problem: Masz tablicę userIds: [1, 2, 3, 4, 5]. Chcesz pobrać dane każdego użytkownika.

  • ❌ Nie możesz użyć useQuery w pętli (hooks nie mogą być w pętlach!)
  • ✅ Użyj useQueries - jeden hook, wiele queries!
function UserProfiles({ userIds }: { userIds: number[] }) {
  const userQueries = useQueries({
    queries: userIds.map(id => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
  });

  // userQueries to tablica wyników
  const isLoading = userQueries.some(q => q.isLoading);
  const users = userQueries.map(q => q.data).filter(Boolean);

  if (isLoading) return <div>Ładowanie...</div>;

  return (
    <div>
      {users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}
🌟 Przykład: Pobierz szczegóły wielu produktów
function CartDetails({ productIds }) {
  const productQueries = useQueries({
    queries: productIds.map(id => ({
      queryKey: ['product', id],
      queryFn: () => fetchProduct(id),
      staleTime: 5 * 60 * 1000, // 5 min
    })),
  });

  const allLoaded = productQueries.every(q => q.isSuccess);
  const totalPrice = productQueries
    .map(q => q.data?.price || 0)
    .reduce((sum, price) => sum + price, 0);

  if (!allLoaded) return <div>Ładowanie koszyka...</div>;

  return (
    <div>
      {productQueries.map((q, i) => (
        <div key={productIds[i]}>
          {q.data?.name} - {q.data?.price} zł
        </div>
      ))}
      <div>Suma: {totalPrice} zł</div>
    </div>
  );
}
useMutation - modyfikowanie danych

useMutation służy do operacji zmieniających dane (POST, PUT, DELETE).

🔍 useQuery vs useMutation - jaka różnica?
useQuery useMutation
Kiedy? GET - czytanie danych POST/PUT/DELETE - zmienianie
Auto fetch? ✅ Automatycznie przy mount ❌ Tylko gdy wywołasz mutate()
Cache? ✅ Tak ❌ Nie
Refetch? ✅ Tak ❌ Nie

Reguła: Query = czytaj, Mutation = zmień! 📖✏️

Podstawowe użycie

import { useMutation } from '@tanstack/react-query';

interface CreateUserDto {
  name: string;
  email: string;
}

function CreateUser() {
  const mutation = useMutation({
    mutationFn: async (userData: CreateUserDto) => {
      const response = await apiClient.post('/users', userData);
      return response.data;
    },
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    mutation.mutate({ 
      name: 'Jan', 
      email: 'jan@example.com' 
    });
  }

  return (
            <form onSubmit={handleSubmit}>
      {/* formularz */}
            <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Tworzenie...' : 'Utwórz'}
      </button>

      {mutation.isError && (
            <div>Błąd: {mutation.error.message}</div>
      )}

      {mutation.isSuccess && (
            <div>Użytkownik utworzony!</div>
      )}
    </form>
  );
}
🔍 mutate() vs mutateAsync() - co wybrać?

mutate() - fire and forget (odpal i zapomnij):

mutation.mutate(data);
console.log('To wykona się natychmiast!');
// NIE czeka na wynik

mutateAsync() - zwraca Promise (możesz await):

await mutation.mutateAsync(data);
console.log('To wykona się PO zakończeniu mutation');
// Czeka na wynik

Kiedy co?

  • ✅ mutate() - większość przypadków (prostsze!)
  • ✅ mutateAsync() - gdy potrzebujesz czekać (np. redirect po sukcesie)

Mutation status i flags

const mutation = useMutation({ mutationFn: createUser });

const {
  mutate,         // Funkcja do wywołania mutation
  mutateAsync,    // Async wersja (zwraca Promise)
  
  data,           // Dane zwrócone przez mutation
  error,          // Błąd (jeśli wystąpił)
  
  status,         // 'idle' | 'pending' | 'error' | 'success'
  
  isPending,      // Czy mutation jest w trakcie
  isError,        // Czy wystąpił błąd
  isSuccess,      // Czy sukces
  isIdle,         // Czy nie rozpoczęta
  
  reset,          // Reset mutation do stanu początkowego
} = mutation;

Callbacks - funkcje wywoływane w różnych momentach

🔍 Lifecycle useMutation - kolejność callbacków:
  1. mutate(data) - wywołujesz mutation
  2. onMutate(data) - zanim wyślesz request (optimistic update!)
  3. mutationFn(data) - wysyłasz request do API
  4. Jeśli sukces:
    • onSuccess(result, data, context)
  5. Jeśli błąd:
    • onError(error, data, context)
  6. onSettled(result, error, data, context) - zawsze na końcu
const mutation = useMutation({
  mutationFn: createUser,
  
  onSuccess: (data, variables, context) => {
    // Wywołane po sukcesie
    console.log('Sukces!', data);
    toast.success('Użytkownik utworzony!');
  },
  
  onError: (error, variables, context) => {
    // Wywołane przy błędzie
    console.error('Błąd!', error);
    toast.error('Nie udało się utworzyć użytkownika');
  },
  
  onSettled: (data, error, variables, context) => {
    // Wywołane zawsze (sukces lub błąd)
    console.log('Mutation zakończona');
  },
});

Invalidation - unieważnienie cache

Po stworzeniu/edycji/usunięciu danych, musisz odświeżyć listę.

🔍 Co to jest invalidation?

Invalidation = oznacz cache jako "nieaktualny" → React Query automatycznie refetch!

Scenariusz bez invalidation:

  1. Pokazujesz listę użytkowników (5 osób)
  2. Tworzysz nowego użytkownika (API dodaje, zwraca sukces)
  3. Lista NADAL pokazuje 5 osób! 😱 (cache nie wie o nowym)

Scenariusz z invalidation:

  1. Pokazujesz listę użytkowników (5 osób)
  2. Tworzysz nowego użytkownika
  3. onSuccess wywołuje invalidateQueries(['users'])
  4. React Query: "Cache ['users'] nieaktualny? Refetch!"
  5. Lista automatycznie pokazuje 6 osób! ✅
import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreateUser() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // Oznacz cache jako nieaktualny - wymusi refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
            <button onClick={() => mutation.mutate(userData)}>
      Utwórz użytkownika
    </button>
  );
}

Co się dzieje?

  1. Wywołujesz mutation.mutate()
  2. API tworzy użytkownika
  3. onSuccess wykonuje invalidateQueries
  4. React Query automatycznie odświeża wszystkie queries z kluczem ['users']
  5. Lista użytkowników aktualizuje się automatycznie!
🌟 Przykład: Invalidation dla różnych operacji
// CREATE - dodaj nowego użytkownika
const createMutation = useMutation({
  mutationFn: createUser,
  onSuccess: () => {
    // Invalidate listę użytkowników
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

// UPDATE - edytuj użytkownika
const updateMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: (data) => {
    // Invalidate listę + konkretnego użytkownika
    queryClient.invalidateQueries({ queryKey: ['users'] });
    queryClient.invalidateQueries({ queryKey: ['user', data.id] });
  },
});

// DELETE - usuń użytkownika
const deleteMutation = useMutation({
  mutationFn: deleteUser,
  onSuccess: (_, deletedUserId) => {
    // Invalidate tylko listę (użytkownik już nie istnieje)
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

Optimistic Updates - optymistyczne aktualizacje

Zaktualizuj UI natychmiast (zakładając że operacja się powiedzie), zanim otrzymasz odpowiedź z serwera.

🔍 Optimistic Update - po co?

Bez optimistic update:

  1. Klikasz "Polub post"
  2. Czekasz 500ms na response...
  3. ❤️ pojawia się dopiero po 500ms
  4. Czujesz lag! Aplikacja "wolna" 😞

Z optimistic update:

  1. Klikasz "Polub post"
  2. ❤️ pojawia się NATYCHMIAST! ⚡
  3. W tle wysyłasz request
  4. Jeśli serwer zwróci błąd → rollback (cofnij)
  5. Aplikacja czuje się "szybko"! 🚀

Przykłady: Like/Unlike, Todo check, Twitter tweet, Facebook reakcje

const mutation = useMutation({
  mutationFn: updateUser,
  
  onMutate: async (updatedUser) => {
    // Anuluj aktywne queries dla tego użytkownika
    await queryClient.cancelQueries({ queryKey: ['user', updatedUser.id] });

    // Zapisz poprzedni stan (dla rollbacku w razie błędu)
    const previousUser = queryClient.getQueryData(['user', updatedUser.id]);

    // Optymistycznie zaktualizuj cache
    queryClient.setQueryData(['user', updatedUser.id], updatedUser);

    // Zwróć context z poprzednim stanem
    return { previousUser };
  },

  onError: (error, variables, context) => {
    // Rollback - przywróć poprzedni stan przy błędzie
    if (context?.previousUser) {
      queryClient.setQueryData(
        ['user', variables.id],
        context.previousUser
      );
    }
  },

  onSettled: (data, error, variables) => {
    // Odśwież dane z serwera (żeby być pewnym)
    queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
  },
});

Rezultat: UI aktualizuje się natychmiast, użytkownik nie czeka na serwer. Jeśli serwer zwróci błąd, zmiany są cofane.

🌟 Przykład: Optimistic Todo toggle
const toggleMutation = useMutation({
  mutationFn: updateTodo,
  
  onMutate: async (updatedTodo) => {
    // 1. Anuluj aktywne fetche (żeby nie nadpisały naszej zmiany)
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // 2. Zapisz poprzedni stan (snapshot)
    const previousTodos = queryClient.getQueryData(['todos']);

    // 3. Optymistycznie zaktualizuj cache
    queryClient.setQueryData(['todos'], (old) =>
      old?.map(todo =>
        todo.id === updatedTodo.id 
          ? { ...todo, completed: !todo.completed }  // Toggle!
          : todo
      )
    );

    // 4. Zwróć snapshot (dla rollback)
    return { previousTodos };
  },

  onError: (err, updatedTodo, context) => {
    // ROLLBACK! Przywróć poprzedni stan
    queryClient.setQueryData(['todos'], context.previousTodos);
    toast.error('Nie udało się zaktualizować');
  },

  onSettled: () => {
    // Tak czy siak, odśwież z serwera (dla pewności)
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

// Użycie:
 toggleMutation.mutate(todo)}  // ⚡ Instant!
/>

Checkbox zmienia się NATYCHMIAST! Nie czujesz lagu! 🎯

💪 Ćwiczenie 3: Zbuduj Like button z optimistic update

Stwórz przycisk Like dla postu:

  1. Query pobiera post (id, title, likes)
  2. Mutation likePost(postId)
  3. Optimistic update:
    • Natychmiast +1 do likes
    • Zapisz poprzedni stan
    • Jeśli błąd → rollback
  4. Przycisk pokazuje ❤️ gdy polubione, 🤍 gdy nie
  5. Disabled podczas isPending
Praktyczny przykład: Todo App

Połączmy wszystko w działającą aplikację.

🔍 Co zobaczymy w Todo App:
  • ✅ useQuery - pobieranie listy todos
  • ✅ useMutation (create) - dodawanie + invalidation
  • ✅ useMutation (toggle) - check/uncheck + optimistic update
  • ✅ useMutation (delete) - usuwanie + invalidation
  • ✅ Loading states (isLoading, isPending)
// api/todos.ts
import { apiClient } from './client';

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export async function fetchTodos(): Promise {
  const response = await apiClient.get('/todos');
  return response.data;
}

export async function createTodo(title: string): Promise {
  const response = await apiClient.post('/todos', { title, completed: false });
  return response.data;
}

export async function updateTodo(todo: Todo): Promise {
  const response = await apiClient.put(`/todos/${todo.id}`, todo);
  return response.data;
}

export async function deleteTodo(id: number): Promise {
  await apiClient.delete(`/todos/${id}`);
}
// components/TodoApp.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { fetchTodos, createTodo, updateTodo, deleteTodo, Todo } from '../api/todos';

function TodoApp() {
  const [newTodoTitle, setNewTodoTitle] = useState('');
  const queryClient = useQueryClient();

  // Query - pobierz todos
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  // Mutation - utwórz todo
  const createMutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setNewTodoTitle(''); // Wyczyść input
    },
  });

  // Mutation - toggle completed
  const toggleMutation = useMutation({
    mutationFn: updateTodo,
    onMutate: async (updatedTodo) => {
      // Optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old) =>
        old?.map(todo =>
          todo.id === updatedTodo.id ? updatedTodo : todo
        )
      );

      return { previousTodos };
    },
    onError: (err, variables, context) => {
      // Rollback przy błędzie
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // Mutation - usuń todo
  const deleteMutation = useMutation({
    mutationFn: deleteTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  function handleCreate() {
    if (newTodoTitle.trim()) {
      createMutation.mutate(newTodoTitle);
    }
  }

  function handleToggle(todo: Todo) {
    toggleMutation.mutate({ ...todo, completed: !todo.completed });
  }

  if (isLoading) return <div>Ładowanie...</div>;

  return (
            <div className="todo-app">
            <h1>Todo List</h1>

      {/* Dodawanie todo */}
            <div className="add-todo">
            <input value={newTodoTitle}
                   onChange={(e) => setNewTodoTitle(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
          placeholder="Nowe zadanie..."
        />
            <button onClick={handleCreate}
                    disabled={createMutation.isPending}>
          {createMutation.isPending ? 'Dodawanie...' : 'Dodaj'}
        </button>
      </div>

      {/* Lista todos */}
            <ul className="todo-list">
        {todos?.map(todo => (
            <li key={todo.id} className={todo.completed ? 'completed' : '' }>
            <input type="checkbox"
                   checked={todo.completed}
                   onChange={() => handleToggle(todo)}
            />
            <span>{todo.title}</span>
            <button onClick={() => deleteMutation.mutate(todo.id)}
              disabled={deleteMutation.isPending}
            >
              Usuń
            </button>
          </li>
        ))}
      </ul>

      {todos?.length === 0 && (
            <p className="empty-state">Brak zadań. Dodaj pierwsze!</p>
      )}
    </div>
  );
}

export default TodoApp;
DevTools - narzędzia deweloperskie

React Query DevTools pokazują wszystkie queries, cache, status.

🔍 Co zobaczysz w DevTools?

DevTools to Twoje "X-ray vision" dla React Query! Możesz zobaczyć:

  • 📊 Wszystkie aktywne queries (query keys, status)
  • 🗄️ Cache data (co jest zapisane w pamięci)
  • ⏱️ Timestamps (kiedy ostatnio pobrano, kiedy będzie stale)
  • 🔄 Czy query jest fetching w tle
  • 🎮 Ręczne triggerowanie refetch/invalidate

Nieocenione przy debugowaniu! "Dlaczego dane się nie odświeżają?" → sprawdź DevTools! 🔍

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
            <QueryClientProvider client={queryClient}>
            <YourApp />
            <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Kliknij ikonę React Query na dole ekranu – zobaczysz:

  • Wszystkie aktywne queries
  • Status każdego query (fresh, stale, fetching)
  • Cache data
  • Query keys
  • Timestamps (znaczniki czasu - kiedy pobrano dane)
Best Practices

1. Używaj query keys konsekwentnie

🔍 Query Keys Factory Pattern

Zamiast pisać query keys na pałę, stwórz "fabrykę" która generuje klucze:

  • ✅ Spójne klucze w całej aplikacji
  • ✅ Autocomplete w TypeScript
  • ✅ Łatwe invalidate (znasz strukturę kluczy)
// ✅ Dobrze - spójne klucze
const QUERY_KEYS = {
  users: ['users'] as const,
  user: (id: number) => ['users', id] as const,
  userPosts: (userId: number) => ['users', userId, 'posts'] as const,
};

useQuery({ queryKey: QUERY_KEYS.users, ... });
useQuery({ queryKey: QUERY_KEYS.user(123), ... });

2. Wydziel API calls do osobnych plików

// ✅ Dobrze - api/users.ts
export const userApi = {
  getAll: () => apiClient.get('/users'),
  getById: (id: number) => apiClient.get(`/users/${id}`),
  create: (data) => apiClient.post('/users', data),
};

3. Używaj optimistic updates dla lepszego UX

// ✅ UI aktualizuje się natychmiast
onMutate: async (newData) => {
  queryClient.setQueryData(['data'], newData);
  return { previousData };
},

4. Invalidate po mutations

// ✅ Zawsze odśwież listę po create/update/delete
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['users'] });
},

5. Ustaw sensowne staleTime

// ✅ Dane rzadko się zmieniające - długi staleTime
useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: 24 * 60 * 60 * 1000, // 24 godziny
});

// ✅ Dane często się zmieniające - krótki staleTime
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 30 * 1000, // 30 sekund
});
Podsumowanie

To była pierwsza część React Query! Nauczyliśmy się:

  • Setup – QueryClient, QueryClientProvider
  • useQuery – pobieranie danych, loading/error/data
  • Query keys – identyfikatory queries
  • Options – staleTime, gcTime, refetch strategies
  • Dependent queries – queries zależne od innych
  • Parallel queries – wiele queries jednocześnie
  • useMutation – POST/PUT/DELETE operations
  • Invalidation – odświeżanie cache
  • Optimistic updates – natychmiastowa aktualizacja UI
  • Todo App – kompletny przykład
  • DevTools – debugowanie queries
  • 5 Best Practices – profesjonalne wzorce

React Query zmienia sposób myślenia o danych w React. Zamiast zarządzać stanem ręcznie, deklarujesz co chcesz pobrać, a React Query zajmuje się resztą!

W kolejnym wpisie poznamy część II React Query – pagination (stronicowanie), infinite scroll (nieskończone przewijanie), prefetching (wstępne pobieranie), hydration (napełnianie cache), zaawansowane wzorce!

Zadanie dla Ciebie

Dodaj React Query do swojej aplikacji:

  1. Skonfiguruj QueryClient i Provider
  2. Zamień useState + useEffect na useQuery (min 3 miejsca)
  3. Użyj useMutation dla create/update/delete
  4. Dodaj invalidation po mutations
  5. Zaimplementuj optimistic update dla jednej operacji
  6. (Bonus) Dodaj staleTime i refetch strategies
🎯 BONUS: Wielki projekt końcowy - Blog z React Query

Stwórz kompletny blog używając React Query:

Features:

  • ✅ Lista postów (useQuery + pagination)
  • ✅ Szczegóły posta (useQuery z dynamic ID)
  • ✅ Tworzenie posta (useMutation + invalidation)
  • ✅ Edycja posta (useMutation + optimistic update)
  • ✅ Usuwanie posta (useMutation + invalidation)
  • ✅ Komentarze (dependent queries - post → comments)
  • ✅ Like/Unlike (useMutation + optimistic update)

Config:

  • staleTime: 2 min (posty rzadko się zmieniają)
  • refetchOnWindowFocus: true
  • Query keys factory pattern
  • DevTools enabled

Bonus challenges:

  • 🏆 Search z debounce i cancel
  • 🏆 Filtry (status, author) z query keys
  • 🏆 Offline support (retry + error handling)
  • 🏆 Loading skeletons (isLoading vs isFetching)

To production-ready aplikacja! Po ukończeniu tego projektu będziesz ekspertem React Query! 🚀✨