Kurs JavaScript i React · Moduł 11: Pobieranie danych i API
Cachowanie danych i Stale-While-Revalidate
W tej lekcji8
Wyobraź sobie, że Twój statek kosmiczny wielokrotnie pyta Centrum Dowodzenia o te same dane o planetach. Za każdym razem czekasz na odpowiedź, nawet jeśli dane się nie zmieniły. To marnotrawstwo! Rozwiązaniem jest cachowanie, czyli przechowywanie danych lokalnie w pamięci podręcznej (cache) i odświeżanie ich w tle.
Dlaczego cachowanie jest ważne?
Porównajmy dwa pokłady tego samego statku.
Bez cache:
- Każda nawigacja = nowe zapytanie = spinner
- Użytkownik widzi "Ładowanie..." nawet dla danych, które już widział
- Zbędne obciążenie serwera i sieci
Z cache:
- Dane wyświetlane natychmiast z pamięci
- Odświeżanie w tle (bez spinnera)
- Lepsze doświadczenie użytkownika
Cache ma jednak cenę: dane w pamięci mogą być nieaktualne. Cała sztuka polega na tym, żeby wiedzieć, kiedy im ufać, a kiedy zapytać ponownie.
Wzorzec Stale-While-Revalidate (SWR)
SWR to strategia cachowania znana z HTTP (dyrektywa stale-while-revalidate w nagłówku Cache-Control) i używana przez biblioteki takie jak SWR czy TanStack Query. Nazwa mówi wszystko:
- Stale: natychmiast pokaż dane z cache (nawet jeśli mogą być nieaktualne)
- While-Revalidate: jednocześnie pobierz świeże dane z serwera w tle
- Po otrzymaniu świeżych danych zaktualizuj widok
Ten sam przepływ rozpisany na kroki wygląda tak:
1// Wizualizacja SWR:
2// Krok 1: Użytkownik wchodzi na stronę
3// -> Cache istnieje? TAK -> Pokaż dane z cache (natychmiast!)
4// -> Jednocześnie: wysyłaj zapytanie do serwera
5
6// Krok 2: Serwer odpowiada
7// -> Dane się zmieniły? TAK -> Zaktualizuj widok i cache
8// -> Dane takie same? -> Nic nie rób
9
10// Krok 3: Użytkownik wraca na stronę
11// -> Powtórz od Kroku 1 (natychmiastowe wyświetlenie!)Użytkownik nie czeka ani przy pierwszym powrocie, ani przy kolejnych, a serwer i tak zostaje zapytany. SWR nie oszczędza więc zapytań, tylko czas oczekiwania.
Prosty cache z Map
Najprostszy cache można zbudować z obiektu Map, czyli słownika klucz-wartość. Kluczem będzie adres URL, a wartością dane razem ze znacznikiem czasu. Mapa leży poza komponentem, więc przeżywa jego odmontowanie. Oto custom hook, który ją wykorzystuje:
1// Globalny cache - żyje poza komponentami
2const cache = new Map();
3
4function useFetchWithCache(url, ttl = 60000) {
5 const [data, setData] = useState(() => {
6 const cached = cache.get(url);
7 if (cached && Date.now() - cached.timestamp < ttl) {
8 return cached.data; // Natychmiastowe dane z cache!
9 }
10 return null;
11 });
12 const [loading, setLoading] = useState(!cache.has(url));
13 const [error, setError] = useState(null);
14
15 useEffect(() => {
16 const controller = new AbortController();
17
18 async function fetchData() {
19 try {
20 // Jeśli mamy cache - nie pokazuj spinnera
21 if (!cache.has(url)) setLoading(true);
22 setError(null);
23
24 const response = await fetch(url, {
25 signal: controller.signal,
26 });
27 if (!response.ok) {
28 throw new Error(`HTTP ${response.status}`);
29 }
30 const result = await response.json();
31
32 // Zapisz w cache z timestampem
33 cache.set(url, {
34 data: result,
35 timestamp: Date.now(),
36 });
37 setData(result);
38 } catch (err) {
39 if (err.name !== 'AbortError') {
40 setError(err.message);
41 }
42 } finally {
43 setLoading(false);
44 }
45 }
46
47 fetchData();
48 return () => controller.abort();
49 }, [url, ttl]);
50
51 return { data, loading, error };
52}Funkcja przekazana do useState (leniwa inicjalizacja) wykona się tylko przy pierwszym renderze i od razu wyciąga dane z cache. Efekt mimo to zawsze pyta serwer, i to jest właśnie "revalidate". Reszta, czyli AbortController, response.ok i stany, pochodzi wprost z poprzednich lekcji.
TTL (Time To Live) - czas życia cache
TTL określa, jak długo dane w cache są uznawane za aktualne. W naszym hooku decyduje on o tym, czy przy montowaniu pokazać dane z pamięci od razu. Świeże zapytanie w tle i tak wyrusza zawsze:
1// TTL = 60000ms (1 minuta)
2// Dane starsze niż 1 minuta zostaną pobrane ponownie
3
4// Krótki TTL (5-30s) - często zmieniające się dane
5// np. ceny kryptowalut, status misji
6const liveData = useFetchWithCache('/api/status', 5000);
7
8// Średni TTL (1-5 min) - umiarkowanie zmienne dane
9// np. lista planet, katalog statków
10const catalog = useFetchWithCache('/api/planets', 60000);
11
12// Długi TTL (10-60 min) - rzadko zmieniające się dane
13// np. konfiguracja, statyczne listy
14const config = useFetchWithCache('/api/config', 600000);Dobór TTL to decyzja biznesowa, nie techniczna. Zadaj sobie pytanie, jak bardzo zaszkodzi użytkownikowi widok danych sprzed minuty.
Invalidacja cache - wymuszenie odświeżenia
Czasem wiemy na pewno, że dane w cache są nieaktualne, na przykład zaraz po dodaniu nowej misji. Wtedy usuwamy wpis ręcznie, czyli invalidujemy cache:
1// Prosta invalidacja - usuń z cache
2function invalidateCache(url) {
3 cache.delete(url);
4}
5
6// Po dodaniu nowej misji - invaliduj listę misji
7async function createMission(data) {
8 await fetch('/api/missions', {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify(data),
12 });
13
14 // Wymuś odświeżenie listy misji
15 invalidateCache('/api/missions');
16}Zasada jest prosta: każda mutacja (POST, PUT, DELETE), która zmienia dane na serwerze, powinna invalidować cache tych danych.
Optimistic Updates - aktualizacja przed odpowiedzią
Zamiast czekać na serwer, możemy zaktualizować UI natychmiast i cofnąć zmianę tylko wtedy, gdy serwer odmówi. Kluczowe jest zapamiętanie poprzedniego stanu, żeby mieć do czego wrócić:
1function MissionList() {
2 const [missions, setMissions] = useState([]);
3
4 const deleteMission = async (id) => {
5 // 1. OPTYMISTYCZNIE usuń z UI (natychmiast!)
6 const previousMissions = [...missions];
7 setMissions(prev => prev.filter(m => m.id !== id));
8
9 try {
10 // 2. Wysyłaj zapytanie DELETE do serwera
11 const response = await fetch(`/api/missions/${id}`, {
12 method: 'DELETE',
13 });
14 // fetch nie rzuca dla 4xx/5xx - sprawdzamy sami
15 if (!response.ok) throw new Error(`HTTP ${response.status}`);
16 // Sukces - UI już zaktualizowany!
17 } catch (error) {
18 // 3. BŁĄD - przywróć poprzedni stan (rollback)
19 setMissions(previousMissions);
20 alert('Nie udało się usunąć misji');
21 }
22 };
23
24 return (
25 <ul>
26 {missions.map(m => (
27 <li key={m.id}>
28 {m.name}
29 <button onClick={() => deleteMission(m.id)}>
30 Usuń
31 </button>
32 </li>
33 ))}
34 </ul>
35 );
36}Sprawdzenie response.ok jest tu niezbędne: bez niego odpowiedź 500 nie trafiłaby do catch i rollback nigdy by się nie wykonał. Optimistic updates polecam przy operacjach, które prawie zawsze się udają, jak polubienie czy usunięcie z listy.
TanStack Query -- profesjonalne cachowanie
W dużych projektach zamiast pisać własny cache, używamy biblioteki TanStack Query (dawniej React Query, obecnie wersja 5). To jak zaawansowany komputer pokładowy, który automatycznie zarządza całą komunikacją. Aplikację owijamy w QueryClientProvider, a dane pobieramy hookiem useQuery:
1import {
2 useQuery,
3 useMutation,
4 useQueryClient,
5 QueryClient,
6 QueryClientProvider,
7} from '@tanstack/react-query';
8
9const queryClient = new QueryClient();
10
11function App() {
12 return (
13 <QueryClientProvider client={queryClient}>
14 <PlanetList />
15 </QueryClientProvider>
16 );
17}
18
19function PlanetList() {
20 // useQuery - automatyczny cache, SWR, retry, refetch
21 const { data, isLoading, error } = useQuery({
22 queryKey: ['planets'],
23 queryFn: () =>
24 fetch('https://swapi.dev/api/planets/')
25 .then(res => res.json()),
26 staleTime: 60000, // Dane świeże przez 1 minutę
27 gcTime: 300000, // Usuń z cache po 5 minutach
28 });
29
30 if (isLoading) return <p>Skanowanie galaktyki...</p>;
31 if (error) return <p>Błąd: {error.message}</p>;
32
33 return (
34 <ul>
35 {data.results.map(planet => (
36 <li key={planet.name}>{planet.name}</li>
37 ))}
38 </ul>
39 );
40}Nie ma tu ani jednego useState ani useEffect: stany ładowania i błędu oraz cache daje biblioteka. Jedno zastrzeżenie z dokumentacji: queryFn musi rzucić błąd, żeby zapytanie trafiło w stan error, a fetch() nie rzuca dla 4xx/5xx. W produkcji dopisz więc sprawdzenie res.ok.
TanStack Query - kluczowe koncepcje
Poniżej najważniejsze opcje w jednym miejscu. queryKey identyfikuje dane w cache, staleTime mówi, jak długo są świeże, a gcTime (w wersji 4 nazywało się cacheTime) kiedy nieużywane dane zostaną usunięte:
1// 1. queryKey - unikalny identyfikator danych w cache
2// Zmiana klucza = nowe zapytanie
3const planets = useQuery({
4 queryKey: ['planets', page],
5 queryFn: () => fetchPlanets(page),
6});
7
8// 2. staleTime - jak długo dane są świeże
9// staleTime: 0 - zawsze odświeżaj (domyślnie)
10// staleTime: 60000 - świeże przez 1 minutę
11// staleTime: Infinity - nigdy nie odświeżaj
12
13// 3. gcTime (garbage collection) - kiedy usunąć z cache
14// gcTime: 300000 - usuń po 5 min nieaktywności
15
16// 4. Automatyczny refetch:
17// - Gdy okno wraca do fokusu
18// - Gdy sieć wraca po rozłączeniu
19// - W regularnych odstępach (refetchInterval)
20
21// 5. useMutation - operacje zapisu z invalidacją
22const mutation = useMutation({
23 mutationFn: (newMission) =>
24 fetch('/api/missions', {
25 method: 'POST',
26 body: JSON.stringify(newMission),
27 }),
28 onSuccess: () => {
29 // Po dodaniu misji - invaliduj cache listy misji
30 queryClient.invalidateQueries({
31 queryKey: ['missions']
32 });
33 },
34});Domyślne staleTime to 0, a gcTime to 5 minut. staleTime: Infinity wyłącza tylko automatyczne odświeżanie, ręczna invalidacja nadal działa. Wewnątrz komponentu klienta pobierasz przez const queryClient = useQueryClient(), a w prawdziwym POST dodaj nagłówek Content-Type z poprzedniej lekcji. Zwróć uwagę, że invalidateQueries to dokładnie nasza ręczna invalidacja, tylko wykonana za Ciebie.
Pamiętaj: cache to pamięć komputera pokładowego, pokazuje to, co już wiesz, a w tle sprawdza, czy galaktyka się nie zmieniła.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Globalny cache z Map
4const cache = new Map();
5let fetchCount = 0;
6
7function useFetchWithCache(url, ttl = 30000) {
8 const [data, setData] = useState(() => {
9 const cached = cache.get(url);
10 if (cached && Date.now() - cached.timestamp < ttl) {
11 return cached.data;
12 }
13 return null;
14 });
15 const [loading, setLoading] = useState(!cache.has(url));
16 const [error, setError] = useState(null);
17 const [source, setSource] = useState('');
18
19 useEffect(() => {
20 const controller = new AbortController();
21
22 // Sprawdz cache
23 const cached = cache.get(url);
24 if (cached && Date.now() - cached.timestamp < ttl) {
25 setData(cached.data);
26 setSource('cache (TTL: ' + Math.round((ttl - (Date.now() - cached.timestamp)) / 1000) + 's left)');
27 setLoading(false);
28 // SWR: pobierz w tle mimo cache
29 }
30
31 async function fetchData() {
32 try {
33 if (!cached) setLoading(true);
34 setError(null);
35 fetchCount++;
36 const currentFetch = fetchCount;
37
38 const response = await fetch(url, { signal: controller.signal });
39 if (!response.ok) throw new Error(`HTTP ${response.status}`);
40 const result = await response.json();
41
42 cache.set(url, { data: result, timestamp: Date.now() });
43 setData(result);
44 setSource('serwer (fetch #' + currentFetch + ')');
45 } catch (err) {
46 if (err.name !== 'AbortError') setError(err.message);
47 } finally {
48 setLoading(false);
49 }
50 }
51
52 fetchData();
53 return () => controller.abort();
54 }, [url, ttl]);
55
56 const invalidate = () => {
57 cache.delete(url);
58 setSource('cache invalidated - refetching...');
59 setData(null);
60 setLoading(true);
61 };
62
63 return { data, loading, error, source, invalidate };
64}
65
66function PlanetPanel() {
67 const { data, loading, error, source, invalidate } = useFetchWithCache(
68 'https://swapi.dev/api/planets/', 30000
69 );
70
71 return (
72 <div className="panel">
73 <h3>Planety</h3>
74 <div className="source-badge">{source || 'loading...'}</div>
75 <button className="btn" onClick={invalidate}>Invaliduj cache</button>
76 {loading && <div className="mini-loading"><div className="spinner"></div></div>}
77 {error && <p className="error-text">{error}</p>}
78 {data && data.results.slice(0, 5).map(p => (
79 <div key={p.name} className="item">{p.name} - {p.climate}</div>
80 ))}
81 </div>
82 );
83}
84
85function PeoplePanel() {
86 const { data, loading, error, source, invalidate } = useFetchWithCache(
87 'https://swapi.dev/api/people/', 30000
88 );
89
90 return (
91 <div className="panel">
92 <h3>Postacie</h3>
93 <div className="source-badge">{source || 'loading...'}</div>
94 <button className="btn" onClick={invalidate}>Invaliduj cache</button>
95 {loading && <div className="mini-loading"><div className="spinner"></div></div>}
96 {error && <p className="error-text">{error}</p>}
97 {data && data.results.slice(0, 5).map(p => (
98 <div key={p.name} className="item">{p.name} - {p.birth_year}</div>
99 ))}
100 </div>
101 );
102}
103
104function CacheStatus() {
105 const [, forceUpdate] = useState(0);
106 useEffect(() => {
107 const interval = setInterval(() => forceUpdate(n => n + 1), 1000);
108 return () => clearInterval(interval);
109 }, []);
110
111 return (
112 <div className="cache-status">
113 <h3>Stan Cache (Map)</h3>
114 <p>Entries: {cache.size}</p>
115 {Array.from(cache.entries()).map(([key, val]) => (
116 <div key={key} className="cache-entry">
117 <span className="cache-key">{key.replace('https://swapi.dev/api/', '/')}</span>
118 <span className="cache-age">{Math.round((Date.now() - val.timestamp) / 1000)}s temu</span>
119 </div>
120 ))}
121 <button className="btn danger" onClick={() => { cache.clear(); forceUpdate(n => n + 1); }}>Wyczysc caly cache</button>
122 </div>
123 );
124}
125
126function App() {
127 const [showPanels, setShowPanels] = useState(true);
128
129 return (
130 <div className="dashboard">
131 <h1>Cache & SWR Demo</h1>
132 <p>Odmontuj panele i zamontuj ponownie - dane pojawia sie natychmiast z cache!</p>
133 <button className="btn toggle" onClick={() => setShowPanels(!showPanels)}>
134 {showPanels ? 'Odmontuj panele' : 'Zamontuj panele (dane z cache!)'}
135 </button>
136 {showPanels && (
137 <div className="grid">
138 <PlanetPanel />
139 <PeoplePanel />
140 </div>
141 )}
142 <CacheStatus />
143 </div>
144 );
145}
146
147export default App;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 oznacza nazwa wzorca 'Stale-While-Revalidate' (SWR)?
2. Kiedy należy 'zinwalidować' (usunąć/odświeżyć) cache w aplikacji React?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kolejność kroków wzorca Stale-While-Revalidate:
- Edytor kodu
Implementacja cache z TTL
- Edytor kodu
Optimistic updates z rollback
- Klikanie w kolejności
Kliknij elementy w kolejności konfiguracji useQuery z TanStack Query: