Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

React Query / TanStack Query - nowoczesne zarządzanie stanem serwerowym

8 min czytania
W tej lekcji6

Panel misji pokazuje profil astronauty w trzech komponentach naraz. Z useEffect i fetch każdy wyśle osobne żądanie, żaden nie zapamięta wyniku, a szybkie przełączanie profili może pokazać dane nie tej osoby. React Query, dziś pod nazwą TanStack Query, zmienia sposób, w jaki pobierasz, cache'ujesz i synchronizujesz stan serwerowy z UI. Jak system nawigacji, który sam śledzi pozycję statku, biblioteka sama zarządza stanem Twoich danych.

Dlaczego React Query zamiast useEffect + fetch?

Zobacz tradycyjne podejście i listę jego problemów w komentarzach:

1// Tradycyjne podejście - WIELE PROBLEMÓW
2function UserProfile({ userId }) {
3  const [user, setUser] = useState(null);
4  const [loading, setLoading] = useState(true);
5  const [error, setError] = useState(null);
6
7  useEffect(() => {
8    setLoading(true);
9    fetch(`/api/users/${userId}`)
10      .then(res => res.json())
11      .then(data => {
12        setUser(data);
13        setLoading(false);
14      })
15      .catch(err => {
16        setError(err);
17        setLoading(false);
18      });
19  }, [userId]);
20
21  // Problemy:
22  // 1. Brak cachingu - te same dane pobierane wielokrotnie
23  // 2. Brak automatycznego refetching
24  // 3. Brak optymistycznych aktualizacji
25  // 4. Brak deduplication requests
26  // 5. Brak obsługi stale data
27  // 6. Trzeba ręcznie zarządzać loading/error states
28  // 7. Problemy z race conditions
29
30  if (loading) return <div>Ładowanie...</div>;
31  if (error) return <div>Błąd: {error.message}</div>;
32  return <div>{user.name}</div>;
33}

Stany loading i error prowadzisz ręcznie, a przy szybkiej zmianie userId starsza odpowiedź może nadpisać nowszą, czyli wystąpi wyścig (race condition). Nic tu nie jest cache'owane ani deduplikowane.

Ten sam komponent z TanStack Query wygląda tak:

1// React Query - ELEGANCKIE I POTĘŻNE
2import { useQuery } from '@tanstack/react-query';
3
4function UserProfile({ userId }) {
5  const { data: user, isPending, error } = useQuery({
6    queryKey: ['user', userId],
7    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
8  });
9
10  if (isPending) return <div>Ładowanie...</div>;
11  if (error) return <div>Błąd: {error.message}</div>;
12  return <div>{user.name}</div>;
13}

Każde zapytanie potrzebuje dwóch rzeczy: queryKey, czyli unikalnego klucza w cache, i queryFn, funkcji zwracającej obietnicę z danymi. Klucz ['user', userId] daje każdemu użytkownikowi osobny wpis, a trzy komponenty z tym samym kluczem wyślą jedno żądanie. isPending oznacza, że danych jeszcze nie ma, więc po obu warunkach user jest już zdefiniowany. Pamiętaj, że fetch nie rzuca błędu przy statusie 404 czy 500, więc sprawdzaj res.ok, jak robi to createMission niżej.

Instalacja i podstawowa konfiguracja

Instalujesz główny pakiet i opcjonalne narzędzia deweloperskie:

1# Instalacja TanStack Query (React Query v5)
2npm install @tanstack/react-query
3npm install @tanstack/react-query-devtools  # Opcjonalne DevTools

DevTools trafiają do paczki tylko w trybie deweloperskim, więc nie musisz ich usuwać przed wdrożeniem.

QueryClient przechowuje cache i domyślne opcje zapytań, a QueryClientProvider udostępnia go aplikacji:

1// App.jsx - Setup QueryClient
2import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
4
5// Stwórz Query Client z konfiguracją
6const queryClient = new QueryClient({
7  defaultOptions: {
8    queries: {
9      staleTime: 1000 * 60 * 5, // 5 minut
10      gcTime: 1000 * 60 * 30, // 30 minut
11      refetchOnWindowFocus: true,
12      refetchOnReconnect: true,
13      retry: 3,
14      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
15    },
16  },
17});
18
19function App() {
20  return (
21    <QueryClientProvider client={queryClient}>
22      <YourApp />
23      {/* DevTools - widoczne tylko w development */}
24      <ReactQueryDevtools initialIsOpen={false} />
25    </QueryClientProvider>
26  );
27}

staleTime mówi, jak długo dane są świeże, a gcTime (w wersji 4 cacheTime), po jakim czasie nieużywany wpis zniknie z pamięci. Domyślnie staleTime to 0, a gcTime 5 minut.

Cykl życia zapytania: status i fetchStatus

Strategia stale-while-revalidate polega na tym, że dane z cache pokazujesz od razu, nawet nieaktualne, a w tle biblioteka pobiera świeże i podmienia je w UI. Żeby wiedzieć, co dzieje się w danej chwili, TanStack Query v5 opisuje zapytanie dwoma niezależnymi polami. status mówi o danych: pending (jeszcze ich nie ma, w wersji 4 loading), error albo success. fetchStatus mówi o funkcji queryFn: fetching (właśnie działa), paused (chce pobierać, ale nie ma sieci) albo idle (nic nie robi). Wartości idle i loading w polu status już nie istnieją:

1function MissionCounter() {
2  const { data, status, fetchStatus, isStale } = useQuery({
3    queryKey: ['missions'],
4    queryFn: fetchMissions,
5    staleTime: 30 * 1000, // Przez 30 sekund dane są świeże
6  });
7
8  if (status === 'pending') return <p>Pierwsze ładowanie (fetchStatus: {fetchStatus})</p>;
9  if (status === 'error') return <p>Błąd pobierania misji</p>;
10
11  return (
12    <p>
13      Misje: {data.length}, dane {isStale ? 'nieaktualne' : 'świeże'}
14      {fetchStatus === 'fetching' && ' - odświeżanie w tle'}
15    </p>
16  );
17}

Tak wygląda typowa droga zapytania od zamontowania komponentu:

  1. Montowanie - nie ma danych, więc status to pending, a fetchStatus to fetching. Tylko w tej sytuacji isLoading, czyli isPending && isFetching, ma wartość true.
  2. Sukces - dane dotarły: status zmienia się na success, a fetchStatus na idle. Przez czas staleTime dane są świeże i biblioteka nie wysyła nowych żądań.
  3. Dane nieaktualne - po upływie staleTime dane stają się nieaktualne (isStale ma wartość true), ale samo to niczego jeszcze nie pobiera. Domyślnie staleTime to 0, więc dane są nieaktualne od razu po pobraniu.
  4. Odświeżanie w tle - wyzwalacz, czyli nowy komponent z tym samym kluczem, powrót do karty przeglądarki (refetchOnWindowFocus), odzyskanie sieci (refetchOnReconnect) albo invalidateQueries, uruchamia pobieranie. status zostaje success i stare dane są nadal widoczne, a fetchStatus zmienia się na fetching, więc isFetching ma wartość true, a isLoading false.
  5. Znowu bezczynność - świeże dane zastępują stare, fetchStatus wraca do idle, a odliczanie staleTime zaczyna się od nowa.

Bez sieci zapytanie nie kończy się błędem, tylko przechodzi w fetchStatus: 'paused' i rusza samo po odzyskaniu połączenia. refetchInterval odświeża dane cyklicznie, niezależnie od staleTime.

useMutation - żądania POST, PUT i DELETE

Zapytania czytają dane, a mutacje je zmieniają. useMutation przyjmuje mutationFn i niczego nie wysyła sam, tylko zwraca obiekt z metodą mutate:

1import { useMutation, useQueryClient } from '@tanstack/react-query';
2
3async function createMission(newMission) {
4  const response = await fetch('/api/missions', {
5    method: 'POST',
6    headers: { 'Content-Type': 'application/json' },
7    body: JSON.stringify(newMission),
8  });
9
10  if (!response.ok) {
11    throw new Error('Failed to create mission');
12  }
13
14  return response.json();
15}
16
17function CreateMissionForm() {
18  const queryClient = useQueryClient();
19
20  const mutation = useMutation({
21    mutationFn: createMission,
22
23    // Optimistic update
24    onMutate: async (newMission) => {
25      await queryClient.cancelQueries({ queryKey: ['missions'] });
26      const previousMissions = queryClient.getQueryData(['missions']);
27      queryClient.setQueryData(['missions'], (old) => [...old, newMission]);
28      return { previousMissions };
29    },
30
31    // Rollback on error
32    onError: (err, newMission, context) => {
33      queryClient.setQueryData(['missions'], context.previousMissions);
34    },
35
36    // Invalidate and refetch
37    onSettled: () => {
38      queryClient.invalidateQueries({ queryKey: ['missions'] });
39    },
40  });
41
42  const handleSubmit = (e) => {
43    e.preventDefault();
44    const formData = new FormData(e.target);
45
46    mutation.mutate({
47      name: formData.get('name'),
48      destination: formData.get('destination'),
49      date: formData.get('date'),
50    });
51  };
52
53  return (
54    <form onSubmit={handleSubmit}>
55      <input name="name" placeholder="Nazwa misji" required />
56      <input name="destination" placeholder="Cel misji" required />
57      <input name="date" type="date" required />
58
59      <button type="submit" disabled={mutation.isPending}>
60        {mutation.isPending ? 'Tworzenie...' : 'Utwórz misję'}
61      </button>
62    </form>
63  );
64}

To optymistyczna aktualizacja: onMutate od razu dopisuje misję do cache, zanim serwer odpowie, i zapamiętuje poprzednią listę. Przy błędzie onError przywraca ją z context, a onSettled unieważnia klucz ['missions'], więc lista i tak zsynchronizuje się z serwerem. cancelQueries zatrzymuje trwające pobieranie, żeby nie nadpisało zmiany, a isPending blokuje przycisk.

Paginacja z React Query

Przy stronicowaniu numer strony trafia do klucza, więc każda strona ma osobny wpis w cache:

1import { useQuery, keepPreviousData } from '@tanstack/react-query';
2
3function PaginatedMissions() {
4  const [page, setPage] = useState(1);
5
6  const { data, isLoading, isPlaceholderData } = useQuery({
7    queryKey: ['missions', page],
8    queryFn: () => fetchMissions(page),
9    placeholderData: keepPreviousData,
10  });
11
12  return (
13    <div>
14      {isLoading ? (
15        <div>Ładowanie...</div>
16      ) : (
17        <>
18          <div className="missions-list">
19            {data.missions.map(mission => (
20              <div key={mission.id} className="mission-item">
21                <h3>{mission.name}</h3>
22              </div>
23            ))}
24          </div>
25
26          <div className="pagination">
27            <button
28              onClick={() => setPage(old => Math.max(old - 1, 1))}
29              disabled={page === 1}
30            >
31              Poprzednia
32            </button>
33
34            <span>Strona {page} z {data.totalPages}</span>
35
36            <button
37              onClick={() => setPage(old => old + 1)}
38              disabled={isPlaceholderData || page >= data.totalPages}
39            >
40              Następna
41            </button>
42          </div>
43        </>
44      )}
45    </div>
46  );
47}

placeholderData: keepPreviousData zostawia na ekranie poprzednią stronę, dopóki nie dotrze następna, więc lista nie miga. isPlaceholderData mówi, że widzisz jeszcze stare dane, dlatego przycisk Następna czeka.

Infinite Queries - nieskończone scrollowanie

useInfiniteQuery przechowuje wszystkie pobrane strony nieskończonej listy w data.pages:

1import { useInfiniteQuery } from '@tanstack/react-query';
2
3function InfiniteMissions() {
4  const {
5    data,
6    fetchNextPage,
7    hasNextPage,
8    isFetchingNextPage,
9    status,
10  } = useInfiniteQuery({
11    queryKey: ['infiniteMissions'],
12    queryFn: ({ pageParam }) => fetch(`/api/missions?page=${pageParam}&limit=20`).then(r => r.json()),
13    initialPageParam: 1,
14    getNextPageParam: (lastPage, allPages) => {
15      return lastPage.hasMore ? allPages.length + 1 : undefined;
16    },
17  });
18
19  if (status === 'pending') return <div>Ładowanie...</div>;
20  if (status === 'error') return <div>Błąd ładowania</div>;
21
22  return (
23    <div>
24      <div className="infinite-list">
25        {data.pages.map((page, i) => (
26          <React.Fragment key={i}>
27            {page.missions.map(mission => (
28              <div key={mission.id} className="mission-card">
29                <h3>{mission.name}</h3>
30              </div>
31            ))}
32          </React.Fragment>
33        ))}
34      </div>
35
36      <button
37        onClick={() => fetchNextPage()}
38        disabled={!hasNextPage || isFetchingNextPage}
39      >
40        {isFetchingNextPage
41          ? 'Ładowanie...'
42          : hasNextPage
43          ? 'Załaduj więcej'
44          : 'Brak więcej danych'}
45      </button>
46    </div>
47  );
48}

initialPageParam (w wersji 5 obowiązkowy) ustala pierwszą stronę, a getNextPageParam zwraca numer następnej albo undefined, gdy dane się skończą, co ustawia hasNextPage na false.

Polecam trzymać dane z serwera w TanStack Query, a w useState czy Reduxie tylko stan interfejsu, jak otwarte panele, bo to dwa różne rodzaje stanu. Biblioteka automatyzuje pobieranie, cache'owanie, synchronizację i aktualizacje danych jak autopilot, który sam koryguje kurs, a Ty skupiasz się na doświadczeniu użytkownika. Przyda się w każdym projekcie z API, bo queryFn może być dowolną funkcją zwracającą obietnicę, także klientem GraphQL.

Pamiętaj: klucz zapytania to współrzędne w cache - te same współrzędne oznaczają te same dane, a nieaktualne dane odświeżą się w tle. W przykładzie poniżej obserwuj pola status i fetchStatus: poczekaj 5 sekund, aż dane się zestarzeją, wróć do karty, a potem dodaj misję, także z symulowanym błędem serwera.

Kod do tej lekcji: App.jsx
1import { useEffect, useState, useSyncExternalStore } from 'react';
2
3// === TanStack Query v5 - uproszczona symulacja cache ===
4// W projekcie: npm install @tanstack/react-query i useQuery({ queryKey, queryFn, staleTime }).
5// Ten mini-klient pokazuje te same pola co biblioteka: status (pending | error | success),
6// fetchStatus (fetching | paused | idle) oraz dane świeże i nieaktualne po upływie staleTime.
7
8const STALE_TIME = 5000; // Przez 5 sekund dane są świeże
9const startedAt = Date.now();
10
11// Fałszywy serwer z listą misji
12let serverMissions = [
13  { id: 1, name: 'Apollo-RQ1', status: 'aktywna', crew: 4 },
14  { id: 2, name: 'Nebula-X7', status: 'zakończona', crew: 6 },
15  { id: 3, name: 'Orion-3', status: 'planowana', crew: 3 },
16];
17
18const api = {
19  getMissions: () => new Promise(resolve => setTimeout(() => resolve([...serverMissions]), 800)),
20  addMission: (mission, fail) => new Promise((resolve, reject) => setTimeout(() => {
21    if (fail) {
22      reject(new Error('Serwer odrzucił misję'));
23      return;
24    }
25    const saved = { ...mission, id: Date.now() };
26    serverMissions = [...serverMissions, saved];
27    resolve(saved);
28  }, 700)),
29};
30
31// Wpis w cache dla klucza ['missions'] - to robi za Ciebie QueryClient
32function createQuery(queryFn) {
33  let state = { status: 'pending', fetchStatus: 'idle', data: undefined, error: null, dataUpdatedAt: 0, isStale: true, log: [] };
34  let inFlight = null;
35  let fetchId = 0;
36  let staleTimer = null;
37  const listeners = new Set();
38
39  const setState = (patch, message) => {
40    const time = ((Date.now() - startedAt) / 1000).toFixed(1) + ' s';
41    const log = message ? [...state.log, { time, message }].slice(-10) : state.log;
42    state = { ...state, ...patch, log };
43    listeners.forEach(listener => listener());
44  };
45
46  const markFresh = (data, message) => {
47    clearTimeout(staleTimer);
48    setState({ status: 'success', data, error: null, dataUpdatedAt: Date.now(), isStale: false, fetchStatus: 'idle' }, message);
49    staleTimer = setTimeout(() => setState({ isStale: true }, 'minął staleTime: dane nieaktualne, ale nic się jeszcze nie pobiera'), STALE_TIME);
50  };
51
52  const query = {
53    subscribe(listener) {
54      listeners.add(listener);
55      return () => listeners.delete(listener);
56    },
57    getState() {
58      return state;
59    },
60    fetch(reason) {
61      if (inFlight) return inFlight; // Deduplikacja: jedno żądanie naraz
62      if (state.status === 'success') {
63        setState({ fetchStatus: 'fetching' }, 'fetchStatus: fetching, status nadal success - odświeżanie w tle (' + reason + ')');
64      } else {
65        setState({ fetchStatus: 'fetching' }, 'status: pending, fetchStatus: fetching - pierwsze ładowanie (isLoading: true)');
66      }
67      const id = ++fetchId;
68      inFlight = queryFn()
69        .then(data => {
70          if (id === fetchId) markFresh(data, 'status: success, fetchStatus: idle - dane świeże przez ' + STALE_TIME / 1000 + ' s');
71        })
72        .catch(error => {
73          if (id === fetchId) setState({ status: 'error', error, fetchStatus: 'idle' }, 'status: error - ' + error.message);
74        })
75        .finally(() => {
76          if (id === fetchId) inFlight = null;
77        });
78      return inFlight;
79    },
80    // cancelQueries: wynik trwającego pobierania zostanie zignorowany
81    cancel() {
82      if (!inFlight) return;
83      fetchId++;
84      inFlight = null;
85      setState({ fetchStatus: 'idle' }, 'cancelQueries: przerwano trwające pobieranie');
86    },
87    refetchIfStale(reason) {
88      if (state.isStale) query.fetch(reason);
89      else if (state.status === 'success') setState({}, reason + ': dane świeże, więc bez nowego żądania');
90    },
91    invalidate(reason) {
92      clearTimeout(staleTimer);
93      setState({ isStale: true });
94      return query.fetch(reason);
95    },
96    setData(updater, message) {
97      markFresh(updater(state.data), message);
98    },
99  };
100  return query;
101}
102
103const missionsQuery = createQuery(api.getMissions);
104
105// Odpowiednik useQuery({ queryKey: ['missions'], queryFn, staleTime: 5000 })
106function useMissionsQuery() {
107  const state = useSyncExternalStore(missionsQuery.subscribe, missionsQuery.getState);
108
109  // Montowanie komponentu: pobierz, jeśli danych nie ma albo są nieaktualne
110  useEffect(() => {
111    missionsQuery.refetchIfStale('nowy komponent');
112  }, []);
113
114  // refetchOnWindowFocus: powrót do karty odświeża tylko nieaktualne dane
115  useEffect(() => {
116    const onFocus = () => missionsQuery.refetchIfStale('powrót do okna');
117    window.addEventListener('focus', onFocus);
118    return () => window.removeEventListener('focus', onFocus);
119  }, []);
120
121  return {
122    ...state,
123    isPending: state.status === 'pending',
124    isFetching: state.fetchStatus === 'fetching',
125    isLoading: state.status === 'pending' && state.fetchStatus === 'fetching',
126  };
127}
128
129function Badge({ label, value, color }) {
130  return (
131    <span style={{ ...styles.badge, borderColor: color, color }}>
132      {label}: <strong>{String(value)}</strong>
133    </span>
134  );
135}
136
137function QueryStatus({ query }) {
138  return (
139    <div style={styles.card}>
140      <div style={styles.badges}>
141        <Badge label="status" value={query.status} color="#00d4ff" />
142        <Badge label="fetchStatus" value={query.fetchStatus} color={query.isFetching ? '#ffa500' : '#8892b0'} />
143        <Badge label="isLoading" value={query.isLoading} color="#8892b0" />
144        <Badge label="isFetching" value={query.isFetching} color="#8892b0" />
145        <Badge label="dane" value={query.isStale ? 'nieaktualne' : 'świeże'} color={query.isStale ? '#ff6b6b' : '#00ff88'} />
146      </div>
147      <div style={styles.staleTrack}>
148        {!query.isStale && <div key={query.dataUpdatedAt} style={styles.staleFill} />}
149      </div>
150      <div style={styles.buttons}>
151        <button style={styles.button} onClick={() => missionsQuery.refetchIfStale('powrót do okna')}>
152          Symuluj powrót do karty
153        </button>
154        <button style={styles.button} onClick={() => missionsQuery.invalidate('invalidateQueries')}>
155          invalidateQueries(['missions'])
156        </button>
157      </div>
158    </div>
159  );
160}
161
162function AddMission({ ready }) {
163  const [name, setName] = useState('');
164  const [failServer, setFailServer] = useState(false);
165  const [isPending, setIsPending] = useState(false);
166
167  // Odpowiednik useMutation z optymistyczną aktualizacją
168  const addMission = async () => {
169    const mission = { name: name.trim(), status: 'planowana', crew: 2 };
170    if (!mission.name || isPending || !ready) return;
171    setName('');
172    setIsPending(true);
173
174    // onMutate: przerwij trwające pobieranie, zapamiętaj listę i od razu dopisz misję
175    missionsQuery.cancel();
176    const previous = missionsQuery.getState().data;
177    missionsQuery.setData(old => [...old, { ...mission, id: 'temp-' + Date.now(), optimistic: true }], 'onMutate: misja dopisana optymistycznie');
178    try {
179      await api.addMission(mission, failServer);
180    } catch (error) {
181      // onError: przywróć listę sprzed zmiany
182      missionsQuery.setData(() => previous, 'onError: ' + error.message + ' - przywrócono poprzednią listę');
183    } finally {
184      // onSettled: unieważnij klucz, żeby zsynchronizować się z serwerem
185      setIsPending(false);
186      missionsQuery.invalidate('onSettled');
187    }
188  };
189
190  return (
191    <div style={styles.form}>
192      <input
193        value={name}
194        onChange={e => setName(e.target.value)}
195        onKeyDown={e => e.key === 'Enter' && addMission()}
196        placeholder="Nazwa nowej misji..."
197        style={styles.input}
198      />
199      <button onClick={addMission} disabled={isPending || !ready} style={styles.primary}>
200        {isPending ? 'Zapisywanie...' : 'Dodaj misję'}
201      </button>
202      <label style={styles.check}>
203        <input type="checkbox" checked={failServer} onChange={e => setFailServer(e.target.checked)} />
204        Symuluj błąd serwera
205      </label>
206    </div>
207  );
208}
209
210export default function App() {
211  const query = useMissionsQuery();
212
213  return (
214    <div style={styles.page}>
215      <div style={{ maxWidth: '640px', margin: '0 auto' }}>
216        <h1 style={styles.title}>TanStack Query v5 - cykl życia zapytania</h1>
217        <p style={styles.subtitle}>staleTime: {STALE_TIME / 1000} s. Poczekaj, aż dane się zestarzeją, a potem wróć do karty.</p>
218
219        <QueryStatus query={query} />
220
221        <div style={styles.card}>
222          {query.isPending ? (
223            <p style={styles.muted}>Ładowanie misji...</p>
224          ) : query.status === 'error' ? (
225            <p style={{ color: '#ff6b6b' }}>Błąd: {query.error.message}</p>
226          ) : (
227            query.data.map(m => (
228              <div key={m.id} style={{ ...styles.mission, opacity: m.optimistic ? 0.6 : 1 }}>
229                <strong>{m.name}</strong>
230                <span style={styles.muted}>
231                  {m.optimistic ? 'zapis w toku (optymistycznie)' : m.status + ', załoga: ' + m.crew}
232                </span>
233              </div>
234            ))
235          )}
236          <AddMission ready={query.status === 'success'} />
237        </div>
238
239        <div style={styles.card}>
240          <h3 style={styles.logTitle}>Dziennik zapytania</h3>
241          {query.log.map((entry, i) => (
242            <div key={i} style={styles.logRow}>
243              <span style={styles.logTime}>{entry.time}</span>
244              <span>{entry.message}</span>
245            </div>
246          ))}
247        </div>
248      </div>
249      <style>{'@keyframes staleCountdown { from { width: 100%; } to { width: 0%; } }'}</style>
250    </div>
251  );
252}
253
254const styles = {
255  page: { minHeight: '100vh', background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)', padding: '20px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
256  title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 6px', fontSize: '20px' },
257  subtitle: { textAlign: 'center', color: '#8892b0', margin: '0 0 14px' },
258  card: { background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(0,212,255,0.15)', borderRadius: '10px', padding: '12px', marginBottom: '12px' },
259  badges: { display: 'flex', flexWrap: 'wrap', gap: '6px' },
260  badge: { border: '1px solid', borderRadius: '6px', padding: '3px 8px', fontSize: '12px' },
261  staleTrack: { height: '4px', background: 'rgba(255,255,255,0.08)', borderRadius: '2px', margin: '10px 0', overflow: 'hidden' },
262  staleFill: { height: '100%', background: '#00ff88', animation: 'staleCountdown 5s linear forwards' },
263  buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap' },
264  button: { padding: '6px 12px', background: 'rgba(0,212,255,0.15)', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.4)', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' },
265  mission: { display: 'flex', justifyContent: 'space-between', gap: '8px', padding: '8px 10px', margin: '6px 0', background: 'rgba(0,212,255,0.06)', borderRadius: '6px' },
266  muted: { color: '#8892b0' },
267  form: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '10px' },
268  input: { flex: '1 1 180px', padding: '7px 10px', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', color: '#e0e1dd' },
269  primary: { padding: '7px 14px', background: '#00d4ff', color: '#0a0e27', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
270  check: { display: 'flex', alignItems: 'center', gap: '6px', color: '#8892b0' },
271  logTitle: { margin: '0 0 8px', color: '#64ffda', fontSize: '14px' },
272  logRow: { display: 'flex', gap: '8px', padding: '2px 0', fontSize: '12px' },
273  logTime: { color: '#8892b0', width: '48px', flexShrink: 0, textAlign: 'right' },
274};

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co jest wymagane w konfiguracji useQuery?

  2. 2. Co to jest optimistic update w kontekście useMutation?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż elementy wywołania useMutation w React Query od lewej do prawej:

  • Układanie w pionie

    Uporządkuj cykl życia zapytania w TanStack Query v5 od zamontowania komponentu do odświeżenia w tle:

Przydatne artykuły