Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
React Query / TanStack Query - nowoczesne zarządzanie stanem serwerowym
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 DevToolsDevTools 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:
- Montowanie - nie ma danych, więc
statustopending, afetchStatustofetching. Tylko w tej sytuacjiisLoading, czyliisPending && isFetching, ma wartośćtrue. - Sukces - dane dotarły:
statuszmienia się nasuccess, afetchStatusnaidle. Przez czasstaleTimedane są świeże i biblioteka nie wysyła nowych żądań. - Dane nieaktualne - po upływie
staleTimedane stają się nieaktualne (isStalema wartośćtrue), ale samo to niczego jeszcze nie pobiera. DomyślniestaleTimeto 0, więc dane są nieaktualne od razu po pobraniu. - Odświeżanie w tle - wyzwalacz, czyli nowy komponent z tym samym kluczem, powrót do karty przeglądarki (
refetchOnWindowFocus), odzyskanie sieci (refetchOnReconnect) alboinvalidateQueries, uruchamia pobieranie.statuszostajesuccessi stare dane są nadal widoczne, afetchStatuszmienia się nafetching, więcisFetchingma wartośćtrue, aisLoadingfalse. - Znowu bezczynność - świeże dane zastępują stare,
fetchStatuswraca doidle, a odliczaniestaleTimezaczyna 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. Co jest wymagane w konfiguracji useQuery?
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: