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 poznaliśmy podstawy React Query – useQuery do pobierania danych, useMutation do ich modyfikowania, cache, invalidation i optimistic updates. To już ogromny krok naprzód w zarządzaniu stanem serwerowym!

Ale prawdziwe aplikacje mają bardziej złożone wymagania: pagination (stronicowanie – dzielenie dużych list na strony), infinite scroll (nieskończone przewijanie – ładowanie kolejnych danych przy scrollowaniu), prefetching (wstępne pobieranie – ładowanie danych zanim użytkownik ich potrzebuje), placeholderData (dane zastępcze – wyświetlanie poprzednich danych podczas ładowania nowych).

🔍 Zaawansowane wzorce React Query - po co?

Problem bez zaawansowanych wzorców:

  • Bez pagination: Ładujesz 10,000 użytkowników naraz → wolno, browser lag
  • Bez infinite scroll: Klikanie "Następna strona" 20 razy → frustrujące UX
  • Bez prefetching: Każde kliknięcie = loading spinner → czujesz lag
  • Bez placeholderData: UI migocze przy zmianie strony → nieprzyjemne

Z zaawansowanymi wzorcami:

  • ✅ Pagination - ładujesz po 20 → szybko, płynnie
  • ✅ Infinite scroll - scrollujesz w dół → automatycznie ładuje → jak social media! 📱
  • ✅ Prefetching - następna strona już w cache → zero lagu! ⚡
  • ✅ PlaceholderData - pokazujesz starą stronę podczas ładowania → brak migotania ✨

W tym wpisie nauczymy się zaawansowanych wzorców React Query, które sprawią, że Twoja aplikacja będzie działać płynnie, szybko i profesjonalnie. Będzie dużo praktycznych przykładów – po tym wpisie będziesz potrafił obsłużyć nawet najbardziej wymagające scenariusze!

Pagination – stronicowanie danych

Podstawowe stronicowanie

Najprostszy sposób na stronicowanie to osobny query dla każdej strony.

🔍 Jak działa pagination w React Query?

Query Key z numerem strony:

 // Strona 1
queryKey: ['users', 1] → cache: User[1-10]

// Strona 2  
queryKey: ['users', 2] → cache: User[11-20]

// Strona 3
queryKey: ['users', 3] → cache: User[21-30]

Każda strona = osobny cache! Gdy wracasz do strony 1 → instant (z cache)! ⚡

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

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

interface PaginatedResponse {
  data: User[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

function UsersList() {
  const [page, setPage] = useState(1);
  const pageSize = 10;

  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['users', page], // Każda strona ma osobny cache
    queryFn: async () => {
      const response = await apiClient.get<PaginatedResponse>(
        `/users?page=${page}&pageSize=${pageSize}`
      );
      return response.data;
    },
    // Utrzymuj poprzednie dane podczas ładowania nowych
    placeholderData: (previousData) => previousData,
  });

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

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

      {/* Paginacja */}
            <div className="pagination">
            <button onClick={() => setPage(p => Math.max(1, p - 1))}
          disabled={page === 1 || isLoading}
        >
          Poprzednia
        </button>

            <span>
          Strona {page} z {data?.totalPages || 1}
        </span>

            <button onClick={() => setPage(p => p + 1)}
          disabled={page === data?.totalPages || isLoading}
        >
          Następna
        </button>
      </div>

      {isLoading && <div className="loading-indicator">Ładowanie...</div>}
    </div>
  );
}

Co tu się dzieje?

  • queryKey: ['users', page] – każda strona ma osobny klucz, więc osobny cache
  • placeholderData: (previousData) => previousData – podczas ładowania nowej strony, wyświetlamy poprzednią (brak migotania UI)
  • Przyciski nawigacji z walidacją granic
🔍 placeholderData - po co?

Bez placeholderData:

// Klikniesz "Następna strona"
→ data = undefined (brak cache dla strony 2)
→ UI pokazuje "Ładowanie..." 
→ MIGOTANIE! 😵 Lista znika!

Z placeholderData:

// Klikniesz "Następna strona"
→ data = previousData (strona 1)
→ UI pokazuje STARĄ listę (bez migotania!)
→ W tle ładuje stronę 2
→ Jak dostanie → płynnie zamienia na nową ✨

UX jest ZNACZNIE lepsze! Brak migotania = profesjonalna aplikacja! 🎯

🌟 Przykład: Porównanie z/bez placeholderData
// ❌ BEZ placeholderData - migotanie
const { data } = useQuery({
  queryKey: ['users', page],
  queryFn: fetchUsers,
});

// Zmiana strony 1 → 2:
// 1. data = undefined
// 2. Render: "Ładowanie..."  ← MIGOTANIE!
// 3. Fetch zakończony
// 4. data = strona 2
// 5. Render: lista użytkowników

// ✅ Z placeholderData - płynnie
const { data } = useQuery({
  queryKey: ['users', page],
  queryFn: fetchUsers,
  placeholderData: prev => prev,
});

// Zmiana strony 1 → 2:
// 1. data = strona 1 (placeholder)
// 2. Render: stara lista  ← BEZ MIGOTANIA!
// 3. Fetch zakończony
// 4. data = strona 2
// 5. Render: nowa lista (płynna zmiana)

Prefetching – wstępne pobieranie następnej strony

Pobierz następną stronę w tle, zanim użytkownik kliknie "Następna".

🔍 Prefetching - magiczny trick na zero lagu!

Bez prefetching:

  1. Jesteś na stronie 1
  2. Klikasz "Następna"
  3. Fetch strony 2 (500ms) → loading spinner 😴
  4. Pokazuje stronę 2

Z prefetching:

  1. Jesteś na stronie 1
  2. React Query w TLE pobiera stronę 2 i zapisuje w cache! 🔮
  3. Klikasz "Następna"
  4. Strona 2 JUŻ W CACHE → instant! ⚡ (0ms!)

Użytkownik czuje: "Wow, ta aplikacja jest BŁYSKAWICZNA!" 🚀

import { useQuery, useQueryClient } from '@tanstack/react-query';

function UsersList() {
  const [page, setPage] = useState(1);
  const queryClient = useQueryClient();
  const pageSize = 10;

  const { data, isLoading } = useQuery({
    queryKey: ['users', page],
    queryFn: async () => {
      const response = await apiClient.get<PaginatedResponse>(
        `/users?page=${page}&pageSize=${pageSize}`
      );
      return response.data;
    },
    placeholderData: (previousData) => previousData,
  });

  // Prefetch następnej strony (w tle!)
  useEffect(() => {
    if (data && page < data.totalPages) {
      // Pobierz następną stronę w tle
      queryClient.prefetchQuery({
        queryKey: ['users', page + 1],
        queryFn: async () => {
          const response = await apiClient.get<PaginatedResponse>(
            `/users?page=${page + 1}&pageSize=${pageSize}`
          );
          return response.data;
        },
      });
    }
  }, [page, data, queryClient, pageSize]);

  // ... reszta komponentu
}
🔍 Kiedy prefetch się wykonuje?
// Scenariusz:

// t=0s: Komponent montuje się, page = 1
→ Fetch strony 1
→ useEffect widzi: page = 1, totalPages = 10
→ Wywołuje prefetchQuery dla strony 2
→ W TLE pobiera stronę 2 do cache

// t=3s: Użytkownik kliknie "Następna"
→ setPage(2)
→ useQuery sprawdza cache dla ['users', 2]
→ JEST! Pokazuje instant! ⚡
→ useEffect widzi: page = 2
→ Prefetch strony 3

// Efekt: ZAWSZE masz następną stronę w cache!
🌟 Przykład: Prefetch z hover (jeszcze szybciej!)
function UsersList() {
  const queryClient = useQueryClient();
  
  // Prefetch gdy użytkownik najedzie na "Następna"
  const handleNextHover = () => {
    if (page < data?.totalPages) {
      queryClient.prefetchQuery({
        queryKey: ['users', page + 1],
        queryFn: () => fetchUsers(page + 1),
      });
    }
  };

  return (
                <div>
      {/* ... lista ... */}
      
                <button onMouseEnter={handleNextHover} // Prefetch na hover!
                        onClick={() => setPage(p => p + 1)}
      >
        Następna
      </button>
    </div>
  );
}

// Scenariusz:
// 1. Użytkownik najeżdża myszką na "Następna"
// 2. Prefetch się odpala (pobiera stronę 2)
// 3. Użytkownik klika (zwykle ~500ms po hover)
// 4. Strona 2 już w cache! INSTANT! ⚡⚡⚡

// To jest ULTRA szybkie! Czujesz się jakby dane
// były lokalne, nie z API! 🚀
💪 Ćwiczenie 1: Pagination z prefetch

Stwórz komponent ProductsList z paginacją:

  1. Pobiera produkty z /api/products?page=X&limit=20
  2. Query key: ['products', page]
  3. placeholderData - pokazuj poprzednią stronę podczas ładowania
  4. Prefetch następnej strony w useEffect
  5. Przyciski "Poprzednia" / "Następna" z disabled
  6. Pokazuj "Strona X z Y"

Bonus: Dodaj prefetch na hover przycisku "Następna"!

Stronicowanie z numerami stron

🔍 Pagination z numerami stron - jak w Google!

Zamiast tylko "Poprzednia/Następna", pokazujesz numery stron:

« Pierwsza  ‹ Poprzednia  [1] [2] 3 [4] [5] ... [47]  Następna ›  Ostatnia »
                                    ↑ aktywna strona

Algorytm "inteligentnej paginacji":

  • Zawsze pokazuj: pierwszą i ostatnią stronę
  • Pokazuj: 2 strony przed i 2 po aktualnej
  • Dodaj "..." gdy są luki
function UsersList() {
  const [page, setPage] = useState(1);
  const pageSize = 10;

  const { data, isLoading } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers(page, pageSize),
    placeholderData: (previousData) => previousData,
  });

  // Generuj przyciski stron
  function generatePageNumbers() {
    if (!data) return [];
    
    const totalPages = data.totalPages;
    const pages: (number | string)[] = [];
    
    // Zawsze pokaż pierwszą stronę
    pages.push(1);
    
    // Strony wokół aktualnej
    for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) {
      if (i !== 1 && i !== totalPages) {
        pages.push(i);
      }
    }
    
    // Zawsze pokaż ostatnią stronę
    if (totalPages > 1) {
      pages.push(totalPages);
    }
    
    return pages;
  }

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

        <div className="pagination">
        <button onClick={() => setPage(1)}
          disabled={page === 1}
        >
          « Pierwsza
        </button>

        <button onClick={() => setPage(p => p - 1)}
          disabled={page === 1}
        >
          ‹ Poprzednia
        </button>

        {generatePageNumbers().map((pageNum, idx) => (
        <button key={idx}
                onClick={() => typeof pageNum === 'number' && setPage(pageNum)}
            disabled={page === pageNum}
            className={page === pageNum ? 'active' : ''}
          >
            {pageNum}
          </button>
        ))}

        <button onClick={() => setPage(p => p + 1)}
          disabled={page === data?.totalPages}
        >
          Następna ›
        </button>

        <button onClick={() => setPage(data?.totalPages || 1)}
          disabled={page === data?.totalPages}
        >
          Ostatnia »
        </button>
      </div>
    </div>
  );
}
Infinite Scroll – nieskończone przewijanie

useInfiniteQuery – automatyczne ładowanie kolejnych stron

useInfiniteQuery to specjalny hook do infinite scroll. Automatycznie zarządza wszystkimi stronami i łączy dane.

🔍 useInfiniteQuery vs useQuery - różnica:
useQuery useInfiniteQuery
Dane JEDNA strona WSZYSTKIE strony naraz
Zmiana strony Zamienia stronę 1 → 2 DODAJE stronę 2 do 1
Use case Pagination (przyciski) Infinite scroll
// useQuery - pagination
data = [user11, user12, ..., user20]  // Tylko strona 2

// useInfiniteQuery - infinite scroll
data.pages = [
  [user1, user2, ..., user10],   // Strona 1
  [user11, user12, ..., user20], // Strona 2
  [user21, user22, ..., user30]  // Strona 3
] // WSZYSTKIE strony!
import { useInfiniteQuery } from '@tanstack/react-query';

interface Post {
  id: number;
  title: string;
  body: string;
}

interface PostsResponse {
  posts: Post[];
  nextCursor: number | null; // null = brak kolejnych stron
}

function InfinitePostsList() {
  const {
    data,           // Wszystkie strony w jednej strukturze
    fetchNextPage,  // Funkcja do pobrania następnej strony
    hasNextPage,    // Czy są kolejne strony?
    isFetchingNextPage, // Czy ładuje kolejną stronę?
    isLoading,
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 0 }) => {
      // pageParam to cursor/offset aktualnej strony
      const response = await apiClient.get<PostsResponse>(
        `/posts?cursor=${pageParam}&limit=10`
      );
      return response.data;
    },
    getNextPageParam: (lastPage) => {
      // Zwróć cursor dla następnej strony lub undefined (brak kolejnych)
      return lastPage.nextCursor ?? undefined;
    },
    initialPageParam: 0, // Początkowy cursor
  });

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

  return (
            <div>
      {/* data.pages to tablica wszystkich pobranych stron */}
      {data.pages.map((page, pageIndex) => (
            <div key={pageIndex}>
          {page.posts.map(post => (
            <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
            </article>
          ))}
        </div>
      ))}

      {/* Przycisk "Załaduj więcej" */}
      {hasNextPage && (
            <button onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Ładowanie...' : 'Załaduj więcej'}
        </button>
      )}

      {!hasNextPage && (
            <p>To wszystkie posty!</p>
      )}
    </div>
  );
}

Co tu się dzieje?

  • queryFn otrzymuje pageParam – numer strony/cursor
  • getNextPageParam – funkcja zwracająca cursor dla następnej strony
  • data.pages – tablica wszystkich pobranych stron
  • fetchNextPage() – pobiera kolejną stronę i dodaje do data.pages
🔍 getNextPageParam - jak działa?

getNextPageParam dostaje OSTATNIĄ stronę i musi zwrócić parametr dla NASTĘPNEJ:

// Scenariusz:

// 1. Pierwsze wywołanie
queryFn({ pageParam: 0 })  // initialPageParam
→ Zwraca: { posts: [...], nextCursor: 100 }

// 2. getNextPageParam dostaje tę stronę
getNextPageParam({ posts: [...], nextCursor: 100 })
→ Zwraca: 100

// 3. fetchNextPage() wywołuje queryFn z pageParam = 100
queryFn({ pageParam: 100 })
→ Zwraca: { posts: [...], nextCursor: 200 }

// 4. getNextPageParam ponownie
getNextPageParam({ posts: [...], nextCursor: 200 })
→ Zwraca: 200

// ... i tak dalej, aż:

getNextPageParam({ posts: [...], nextCursor: null })
→ Zwraca: undefined  // KONIEC! hasNextPage = false

Automatyczny infinite scroll (bez przycisku)

Załaduj kolejne dane automatycznie gdy użytkownik dojedzie do końca listy.

🔍 Intersection Observer - jak działa?

Intersection Observer = API które wykrywa czy element jest widoczny na ekranie.

// Tworzymy "sentinel" (wartownika) na końcu listy:
<div ref={observerTarget}>Loading...</div>

// Intersection Observer obserwuje tego sentinela:
observer.observe(observerTarget)

// Gdy użytkownik scrolluje w dół i sentinel staje się widoczny:
isIntersecting = true → fetchNextPage()

// Nowe posty się ładują i dodają do listy
// Sentinel "spada" w dół (bo lista jest dłuższa)
// Użytkownik scrolluje dalej...
// Sentinel znowu widoczny → fetchNextPage()
// ... i tak w kółko! 🔄

Efekt: Jak Facebook/Twitter/Instagram - scrollujesz w nieskończoność! 📱

import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';

function AutoInfinitePostsList() {
  const observerTarget = useRef(null);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialPageParam: 0,
  });

  // Intersection Observer – wykrywa gdy element jest widoczny
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        // Jeśli sentinel (wartownik – element na końcu listy) jest widoczny
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage(); // Załaduj następną stronę
        }
      },
      { threshold: 1.0 } // 100% elementu musi być widoczne
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

  return (
            <div>
      {data?.pages.map((page, pageIndex) => (
            <div key={pageIndex}>
          {page.posts.map(post => (
            <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
            </article>
          ))}
        </div>
      ))}

      {/* Sentinel – element obserwowany przez Intersection Observer */}
            <div ref={observerTarget} style={{ height: '20px' }}>
        {isFetchingNextPage && <div>Ładowanie kolejnych postów...</div>}
      </div>

      {!hasNextPage && (
            <p>To wszystkie posty!</p>
      )}
    </div>
  );
}

Rezultat: Gdy użytkownik scrolluje w dół i dociera do końca listy, automatycznie ładują się kolejne posty. Bez klikania w żaden przycisk!

🌟 Przykład: Infinite scroll z react-intersection-observer
// Prostsza wersja z biblioteką!
npm install react-intersection-observer

import { useInView } from 'react-intersection-observer';

function AutoInfinitePostsList() {
  const { ref, inView } = useInView();

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialPageParam: 0,
  });

  // Gdy sentinel jest widoczny → fetch
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
                <div>
      {data?.pages.map((page, i) => (
                <div key={i}>
          {page.posts.map(post => (
                <article key={post.id}>
                <h2>{post.title}</h2>
                <p>{post.body}</p>
            </article>
          ))}
        </div>
      ))}

      {/* Sentinel z ref z biblioteki */}
                <div ref={ref}>
        {isFetchingNextPage && <div>Ładowanie...</div>}
      </div>
    </div>
  );
}

Bi-directional infinite scroll (dwukierunkowy)

Ładuj dane w górę i w dół (jak Twitter feed).

🔍 Bi-directional scroll - po co?

Use case: Chat, Twitter feed, komunikator

  • ✅ Scrollujesz w górę → ładuje starsze wiadomości
  • ✅ Scrollujesz w dół → ładuje nowsze wiadomości
  • ✅ Zawsze widzisz "środek" konwersacji

Przykład: Wchodzisz na chat i widzisz ostatnie 20 wiadomości. Scrollujesz w górę → ładuje poprzednie 20. Scrollujesz w dół → ładuje następne 20. Płynnie! 💬

function BidirectionalInfiniteList() {
  const {
    data,
    fetchNextPage,      // Pobierz następną stronę (w dół)
    fetchPreviousPage,  // Pobierz poprzednią stronę (w górę)
    hasNextPage,
    hasPreviousPage,
    isFetchingNextPage,
    isFetchingPreviousPage,
  } = useInfiniteQuery({
    queryKey: ['messages'],
    queryFn: async ({ pageParam = 0 }) => {
      const response = await apiClient.get(
        `/messages?cursor=${pageParam}&limit=20`
      );
      return response.data;
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    getPreviousPageParam: (firstPage) => firstPage.previousCursor ?? undefined,
    initialPageParam: 0,
  });

  return (
            <div>
      {/* Przycisk załaduj starsze (górą) */}
      {hasPreviousPage && (
            <button onClick={() => fetchPreviousPage()}>
          {isFetchingPreviousPage ? 'Ładowanie...' : 'Załaduj starsze'}
        </button>
      )}

      {data?.pages.map((page, idx) => (
            <div key={idx}>
          {page.messages.map(msg => (
            <div key={msg.id}>{msg.text}</div>
          ))}
        </div>
      ))}

      {/* Przycisk załaduj nowsze (dołem) */}
      {hasNextPage && (
            <button onClick={() => fetchNextPage()}>
          {isFetchingNextPage ? 'Ładowanie...' : 'Załaduj nowsze'}
        </button>
      )}
    </div>
  );
}
💪 Ćwiczenie 2: Infinite scroll dla produktów

Stwórz komponent InfiniteProductsList:

  1. useInfiniteQuery dla produktów
  2. Query key: ['products']
  3. Endpoint: /api/products?cursor=X&limit=20
  4. getNextPageParam zwraca nextCursor z response
  5. Automatyczny infinite scroll (Intersection Observer)
  6. Pokazuj "Ładowanie..." gdy isFetchingNextPage
  7. "Koniec listy!" gdy !hasNextPage

Bonus: Użyj biblioteki react-intersection-observer!

Cursor-based vs Offset-based pagination

Offset-based (limit/offset)

🔍 Offset-based - jak działa i dlaczego ma problemy?

Offset-based: "Pomiń X rekordów, weź Y następnych"

// Strona 1: skip=0, take=10  → rekordy 1-10
// Strona 2: skip=10, take=10 → rekordy 11-20
// Strona 3: skip=20, take=10 → rekordy 21-30

Problem - duplikaty/pominięcia:

// Scenariusz:
// 1. Pobierasz stronę 1 (posty 1-10)
// 2. Ktoś dodaje nowy post na początku listy
// 3. Pobierasz stronę 2 (skip=10)
// 4. Ale teraz post #10 stał się postem #11!
// 5. Widzisz post #10 DRUGI RAZ! (duplikat) 😱

// Lub odwrotnie:
// 1. Pobierasz stronę 1
// 2. Ktoś usuwa post #5
// 3. Pobierasz stronę 2 (skip=10)
// 4. Ale teraz pominąłeś post który był #11! 💥
// ?page=2&pageSize=10 -> skip=20, take=10
queryFn: async ({ pageParam = 0 }) => {
  const response = await apiClient.get(
    `/posts?skip=${pageParam * 10}&take=10`
  );
  return response.data;
},
getNextPageParam: (lastPage, allPages) => {
  // Jeśli zwrócono mniej niż 10, nie ma kolejnych
  if (lastPage.length < 10) return undefined;
  return allPages.length; // Następna strona
},

Wady: Jeśli dane są dodawane/usuwane, możesz zobaczyć duplikaty lub pominąć rekordy.

Cursor-based (zalecane!)

🔍 Cursor-based - dlaczego lepsze?

Cursor-based: "Daj mi posty AFTER kursora X"

// Strona 1: cursor=null     → posty od początku, zwraca cursor="abc123"
// Strona 2: cursor="abc123" → posty AFTER "abc123", zwraca cursor="def456"
// Strona 3: cursor="def456" → posty AFTER "def456"

Zalety - brak duplikatów/pominięć:

// Scenariusz:
// 1. Pobierasz cursor=null → posty 1-10, cursor="abc"
// 2. Ktoś dodaje 5 nowych postów na początku
// 3. Pobierasz cursor="abc" → posty AFTER "abc"
// 4. Dostaniesz posty 11-20 (NIE duplikaty!) ✅

// Cursor ZAWSZE wskazuje konkretny punkt w danych,
// niezależnie od tego co się dzieje z listą!
// ?cursor=abc123&limit=10
queryFn: async ({ pageParam = null }) => {
  const url = pageParam 
    ? `/posts?cursor=${pageParam}&limit=10`
    : '/posts?limit=10';
  const response = await apiClient.get(url);
  return response.data;
},
getNextPageParam: (lastPage) => {
  return lastPage.nextCursor ?? undefined;
},

Zalety: Nie ma duplikatów ani pominiętych rekordów, nawet jeśli dane się zmieniają.

Prefetching – wstępne pobieranie danych

Prefetch przy hover (najechaniu myszą)

🔍 Prefetch przy hover - sekretna broń na błyskawiczną nawigację!

Psychologia użytkownika:

  1. Użytkownik najeżdża myszką na link (~500ms)
  2. Zastanawia się "czy kliknąć?" (~300ms)
  3. Klika (~100ms)
  4. Łącznie: ~900ms ZANIM kliknie!

Twoja szansa: W tych 900ms możesz prefetch danych! Gdy kliknie → już w cache! ⚡

Efekt: Użytkownik czuje że aplikacja reaguje NATYCHMIAST. Magia! 🪄

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

function UserListItem({ user }: { user: User }) {
  const queryClient = useQueryClient();

  function handleMouseEnter() {
    // Prefetch danych użytkownika przy hover
    queryClient.prefetchQuery({
      queryKey: ['user', user.id],
      queryFn: () => fetchUserDetails(user.id),
      staleTime: 60 * 1000, // Cache na 60 sekund
    });
  }

  return (
            <Link to={`/users/${user.id}`}
                  onMouseEnter={handleMouseEnter} // Prefetch przy hover>
      {user.name}
    </Link>
  );
}

Rezultat: Gdy użytkownik najedzie na link, dane są pobierane w tle. Gdy kliknie link, dane są już w cache – strona ładuje się błyskawicznie!

Prefetch kilku queries naraz

🔍 Kiedy prefetch wiele queries?

Use case: Dashboard - wiesz że użytkownik potrzebuje wielu danych jednocześnie

  • ✅ Prefetch przy montowaniu dashboardu
  • ✅ Wszystkie dane ładują się równolegle w tle
  • ✅ Użytkownik widzi "Ładowanie..." JEDEN raz
  • ✅ Potem wszystko instant! 🚀
function Dashboard() {
  const queryClient = useQueryClient();

  useEffect(() => {
    // Prefetch wszystkich kluczowych danych przy montowaniu
    queryClient.prefetchQuery({
      queryKey: ['stats'],
      queryFn: fetchStats,
    });

    queryClient.prefetchQuery({
      queryKey: ['notifications'],
      queryFn: fetchNotifications,
    });

    queryClient.prefetchQuery({
      queryKey: ['recent-activity'],
      queryFn: fetchRecentActivity,
    });
  }, [queryClient]);

  return <div>Dashboard...</div>;
}

Initial data – dane początkowe z cache

Użyj danych z jednego query jako initial data dla drugiego.

🔍 initialData - oszczędzaj requesty!

Scenariusz:

  1. Masz listę użytkowników w cache (z poprzedniego query)
  2. Użytkownik klika na konkretnego użytkownika
  3. Zamiast czekać na fetch szczegółów...
  4. ...wyciągnij podstawowe info z listy jako initialData! ⚡

Rezultat:

  • UI pokazuje dane NATYCHMIAST (z listy)
  • W tle fetch pełnych szczegółów
  • Jak dostanie → płynnie updatuje
  • Użytkownik nie widzi loading spinnera! ✨
function UserProfile({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserDetails(userId),
    initialData: () => {
      // Spróbuj znaleźć użytkownika w cache listy użytkowników
      const users = queryClient.getQueryData(['users']);
      return users?.find(u => u.id === userId);
    },
    initialDataUpdatedAt: () => {
      // Kiedy dane 'users' zostały zaktualizowane?
      return queryClient.getQueryState(['users'])?.dataUpdatedAt;
    },
  });

  // Jeśli użytkownik był w liście, wyświetli się natychmiast!
  // W tle React Query i tak pobierze pełne dane

  return <div>{user?.name}</div>;
}
🌟 Przykład: initialData w praktyce (lista → szczegóły)
// Scenariusz z e-commerce:

// 1. Lista produktów (skrócone dane)
const { data: products } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
});
// Cache: [
//   { id: 1, name: 'Laptop', price: 3000 },
//   { id: 2, name: 'Mouse', price: 50 }
// ]

// 2. Użytkownik klika na produkt #1
<Link to="/products/1">Laptop</Link>

// 3. Strona szczegółów produktu
const { data: product } = useQuery({
  queryKey: ['product', 1],
  queryFn: () => fetchProductDetails(1),
  initialData: () => {
    // Wyciągnij z listy!
    const products = queryClient.getQueryData(['products']);
    return products?.find(p => p.id === 1);
  },
});

// Rezultat:
// → UI pokazuje "Laptop 3000 zł" INSTANT (z listy)
// → W tle fetch pełnych danych (opis, zdjęcia, opinie)
// → Jak dostanie → dodaje opis, zdjęcia, opinie
// → Użytkownik NIE czeka! Czuje instant navigation! 🚀
placeholderData – dane zastępcze

Wyświetlaj poprzednie dane podczas ładowania nowych (bez migotania).

🔍 placeholderData vs initialData - różnica:
initialData placeholderData
Zapisuje w cache? ✅ Tak ❌ Nie
Kiedy używać? Masz PRAWDZIWE dane (z innego query) Chcesz pokazać STARE dane podczas ładowania
Use case Lista → szczegóły Pagination, filtry
function ProductsList() {
  const [filter, setFilter] = useState('all');

  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['products', filter],
    queryFn: () => fetchProducts(filter),
    placeholderData: (previousData) => previousData,
    // Podczas zmiany filtra, pokaż poprzednie dane
  });

  return (
            <div>
            <select value={filter} onChange={e => setFilter(e.target.value)}>
            <option value="all">Wszystkie</option>
            <option value="electronics">Elektronika</option>
            <option value="clothing">Odzież</option>
      </select>

      {/* isFetching = true podczas ładowania nowych, ale UI nie migota */}
      {isFetching && <div className="loading-overlay">Aktualizacja...</div>}

            <div className={isFetching ? 'opacity-50' : '' }>
        {data?.map(product => (
            <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

Rezultat: Zmiana filtra nie powoduje pustego ekranu – poprzednie produkty są widoczne z lekkim przyciemnieniem podczas ładowania nowych.

Select – transformacja danych

Przetransformuj dane zaraz po pobraniu (przed zapisaniem do cache).

🔍 select - po co transformować?

Problem bez select:

// API zwraca:
{
  id: 1,
  firstName: 'Jan',
  lastName: 'Kowalski',
  email: 'jan@test.com',
  age: 30,
  address: {...},
  preferences: {...}
}

// Ale w dropdown potrzebujesz TYLKO:
{ id: 1, fullName: 'Jan Kowalski' }

// Bez select - transformujesz w komponencie:
{data?.map(user => ({
  id: user.id,
  fullName: `${user.firstName} ${user.lastName}`
}))}
// To się wykonuje PRZY KAŻDYM RENDER! 😱

Z select - transformacja raz:

select: (users) => users.map(u => ({
  id: u.id,
  fullName: `${u.firstName} ${u.lastName}`
}))
// Wykonuje się RAZ przy fetch, nie przy render! ✅
interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
}

function UserDropdown() {
  // Pobierz tylko imiona i nazwiska, pomiń resztę
  const { data: userNames } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    select: (users: User[]) => {
      // Transformuj dane - zwróć tylko to co potrzebne
      return users.map(user => ({
        id: user.id,
        fullName: `${user.firstName} ${user.lastName}`,
      }));
    },
  });

  // userNames ma typ: { id: number, fullName: string }[]

  return (
            <select>
      {userNames?.map(user => (
            <option key={user.id} value={user.id}>
          {user.fullName}
        </option>
      ))}
    </select>
  );
}

Zalety:

  • Mniej danych w komponencie (tylko to co potrzebne)
  • select działa na cache – transformacja raz na fetch, nie przy każdym render
  • Łatwiejsze testowanie (dane w prostszym formacie)
🌟 Przykład: select w różnych use cases
// 1. Filtrowanie (tylko aktywni użytkownicy)
const { data: activeUsers } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.filter(u => u.isActive)
});

// 2. Sortowanie (alfabetycznie)
const { data: sortedUsers } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => [...users].sort((a, b) => 
    a.name.localeCompare(b.name)
  )
});

// 3. Grupowanie (po roli)
const { data: usersByRole } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => {
    return users.reduce((acc, user) => {
      const role = user.role;
      if (!acc[role]) acc[role] = [];
      acc[role].push(user);
      return acc;
    }, {});
  }
});
// Rezultat: { admin: [...], user: [...], guest: [...] }

// 4. Wyciągnięcie jednego pola (tylko emaile)
const { data: emails } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.map(u => u.email)
});
// Rezultat: ['jan@test.com', 'anna@test.com', ...]
Dependent Queries z enabled

Wykonaj query tylko gdy inne się zakończą i spełnią warunki.

🔍 enabled z warunkami złożonymi

enabled może mieć DOWOLNY warunek:

// Prosty warunek
enabled: !!userId

// Złożony warunek
enabled: userLoaded && user?.isActive && user?.role === 'admin'

// Z multiple dependencies
enabled: !!userId && !!teamId && hasPermission

// Warunkowy fetch
enabled: searchTerm.length > 2  // Szukaj tylko gdy 3+ znaki
function UserPosts({ userId }: { userId: number }) {
  // Krok 1: Pobierz użytkownika
  const { data: user, isSuccess: userLoaded } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Krok 2: Pobierz posty TYLKO gdy user jest załadowany i aktywny
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: userLoaded && user?.isActive === true,
    // Wykonaj tylko gdy użytkownik jest pobrany i aktywny
  });

  if (!userLoaded) return <div>Ładowanie użytkownika...</div>;
  if (!user?.isActive) return <div>Użytkownik nieaktywny</div>;

  return (
            <div>
            <h1>{user.name}</h1>
      {posts?.map(post => (
            <article key={post.id}>{post.title}</article>
      ))}
    </div>
  );
}
Query Cancellation – anulowanie zapytań

Anuluj poprzednie query gdy użytkownik wpisuje w search (debounce).

🔍 Query Cancellation - po co?

Race condition (wyścig) - problem:

// Użytkownik szybko wpisuje: "John Smith"

// t=0ms: Wpisuje "J" → Request #1 (search "J")
// t=50ms: Wpisuje "Jo" → Request #2 (search "Jo")
// t=100ms: Wpisuje "Joh" → Request #3 (search "Joh")
// ...

// Problem: Request #1 może wrócić PÓŹNIEJ niż #3!
// t=500ms: Response #3 wraca (wyniki dla "Joh")
// t=600ms: Response #1 wraca (wyniki dla "J") ← STARE!
// UI pokazuje stare wyniki dla "J" zamiast "Joh"! 💥

Rozwiązanie - cancel + debounce:

// 1. Debounce - czeka 300ms
// 2. Cancel - anuluje poprzednie requesty
// 3. Tylko OSTATNI request się wykonuje
// 4. Brak race conditions! ✅
function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState('');

  // Debounce - aktualizuj debouncedTerm po 300ms od ostatniej zmiany
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, 300);

    return () => clearTimeout(timer);
  }, [searchTerm]);

  const { data: users, isFetching } = useQuery({
    queryKey: ['users', 'search', debouncedTerm],
    queryFn: async ({ signal }) => {
      // signal - AbortSignal do anulowania request
      const response = await apiClient.get('/users/search', {
        params: { q: debouncedTerm },
        signal, // Axios automatycznie anuluje przy zmianie query
      });
      return response.data;
    },
    enabled: debouncedTerm.length > 0, // Szukaj tylko gdy coś wpisano
  });

  return (
            <div>
            <input type="text"
                   value={searchTerm}
                   onChange={e => setSearchTerm(e.target.value)}
        placeholder="Szukaj użytkowników..."
      />

      {isFetching && <div>Szukanie...</div>}

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

Co tu się dzieje?

  • Użytkownik pisze "John"
  • Każda litera tworzy nowy query key
  • React Query automatycznie anuluje poprzednie requesty (dzięki signal)
  • Tylko ostatni request (dla "John") się wykona
  • Brak race conditions (wyścigów – sytuacji gdy starsza odpowiedź przychodzi po nowszej)
💪 Ćwiczenie 3: Search z debounce i cancel

Stwórz komponent ProductSearch:

  1. Input do wpisywania frazy
  2. Debounce 500ms (używaj useState + useEffect)
  3. Query key: ['products', 'search', debouncedTerm]
  4. enabled: term.length >= 3 (szukaj od 3 znaków)
  5. Używaj signal w queryFn dla cancellation
  6. Pokazuj "Szukanie..." gdy isFetching
  7. Pokazuj liczbę wyników
  8. "Minimum 3 znaki" gdy term.length < 3
Best Practices – dobre praktyki

1. Zawsze używaj placeholderData dla pagination

// ✅ Dobrze - brak migotania
useQuery({
  queryKey: ['items', page],
  queryFn: () => fetchItems(page),
  placeholderData: (previousData) => previousData,
});

// ❌ Źle - UI migota przy każdej zmianie strony
useQuery({
  queryKey: ['items', page],
  queryFn: () => fetchItems(page),
});

2. Prefetch następną stronę dla lepszego UX

// ✅ Następna strona ładuje się błyskawicznie
useEffect(() => {
  if (hasNextPage) {
    queryClient.prefetchQuery({
      queryKey: ['items', page + 1],
      queryFn: () => fetchItems(page + 1),
    });
  }
}, [page]);

3. Cursor-based pagination dla dynamicznych danych

// ✅ Dobrze - brak duplikatów
getNextPageParam: (lastPage) => lastPage.nextCursor,

// ❌ Unikaj offset dla danych często się zmieniających
getNextPageParam: (lastPage, allPages) => allPages.length,

4. Debounce dla search queries

// ✅ Dobrze - jeden request po 300ms
const [debouncedTerm, setDebouncedTerm] = useState('');

useEffect(() => {
  const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300);
  return () => clearTimeout(timer);
}, [searchTerm]);

useQuery({
  queryKey: ['search', debouncedTerm],
  queryFn: () => search(debouncedTerm),
});

5. Używaj select do transformacji danych

// ✅ Transformacja raz, nie przy każdym render
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.map(u => ({ id: u.id, name: u.fullName })),
});
Podsumowanie

Zrobiliśmy gigantyczny krok naprzód! Nauczyliśmy się:

  • Pagination – stronicowanie z prefetching
  • useInfiniteQuery – nieskończone przewijanie
  • Automatic infinite scroll – ładowanie przy scrollowaniu
  • Bi-directional scroll – górą i dołem
  • Cursor vs Offset – które podejście wybrać
  • Prefetching – wstępne pobieranie danych
  • initialData – oszczędzanie requestów
  • placeholderData – brak migotania UI
  • select – transformacja danych
  • Query cancellation – anulowanie requestów
  • 5 Best Practices – profesjonalne wzorce

React Query daje Ci supermoce! Pagination, infinite scroll, prefetching – wszystko działa automatycznie i płynnie. Użytkownicy będą zachwyceni szybkością Twojej aplikacji.

W kolejnym wpisie przejdziemy do autentykacji i autoryzacji – login, rejestracja, JWT tokens, protected routes, refresh tokens. Zbudujemy kompletny system bezpieczeństwa!

Zadanie dla Ciebie

Ulepsz swoją aplikację zaawansowanymi wzorcami:

  1. Dodaj pagination z prefetching następnej strony
  2. Zaimplementuj infinite scroll w minimum jednym miejscu
  3. Dodaj search z debounce i query cancellation
  4. Użyj placeholderData dla płynnych przejść
  5. Dodaj prefetching przy hover na linkach
  6. (Bonus) Użyj select do transformacji danych w min 2 miejscach
🎯 BONUS: Wielki projekt końcowy - Advanced E-commerce

Stwórz zaawansowaną listę produktów wykorzystując WSZYSTKIE wzorce:

Features:

  • ✅ Pagination z numerami stron (jak Google)
  • ✅ Prefetch następnej strony + hover na linki
  • ✅ Filtry (kategoria, cena, dostępność)
  • ✅ Search z debounce (500ms) i cancel
  • ✅ placeholderData - brak migotania przy filtrach
  • ✅ select - transformuj do formatu UI
  • ✅ initialData - lista → szczegóły produktu
  • ✅ Infinite scroll na mobile (useInfiniteQuery)

Query Keys Pattern:

const QUERY_KEYS = {
  products: (filters) => ['products', filters],
  product: (id) => ['product', id],
  search: (term) => ['products', 'search', term],
};

Bonus challenges:

  • 🏆 URL sync - filtry w query params
  • 🏆 Skeleton loading (isLoading vs isFetching)
  • 🏆 Empty states (brak wyników)
  • 🏆 Error recovery (retry button)
  • 🏆 Loading states UX (opacity, overlays)

To production-ready e-commerce! Po ukończeniu tego projektu będziesz mistrzem React Query! 🚀✨