Kurs JavaScript i React · Moduł 11: Pobieranie danych i API

Cachowanie danych i Stale-While-Revalidate

7 min czytania
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:

  1. Stale: natychmiast pokaż dane z cache (nawet jeśli mogą być nieaktualne)
  2. While-Revalidate: jednocześnie pobierz świeże dane z serwera w tle
  3. 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. 1. Co oznacza nazwa wzorca 'Stale-While-Revalidate' (SWR)?

  2. 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:

Przydatne artykuły