JavaScript and React course Β· Module 11: Data Fetching and APIs
Data Caching and Stale-While-Revalidate
In this lesson8
Imagine your spaceship repeatedly asking Mission Control for the same planetary data. Each time you wait for a response, even if the data hasn't changed. What a waste! The solution is caching: storing data locally in a cache and refreshing it in the background.
Why is Caching Important?
Let's compare two decks of the same ship.
Without cache:
- Every navigation = new request = spinner
- The user sees "Loading..." even for data they've already seen
- Unnecessary load on the server and network
With cache:
- Data displayed instantly from memory
- Background refresh (no spinner)
- Better user experience
A cache has a price, though: the data in memory may be outdated. The whole art is knowing when to trust it and when to ask again.
The Stale-While-Revalidate (SWR) Pattern
SWR is a caching strategy known from HTTP (the stale-while-revalidate directive of the Cache-Control header) and used by libraries such as SWR and TanStack Query. The name says it all:
- Stale: immediately show data from the cache (even if it may be outdated)
- While-Revalidate: at the same time, fetch fresh data from the server in the background
- When the fresh data arrives, update the view
The same flow written out as steps looks like this:
1// SWR Visualization:
2// Step 1: User enters the page
3// -> Cache exists? YES -> Show cached data (instantly!)
4// -> Simultaneously: send request to server
5
6// Step 2: Server responds
7// -> Data changed? YES -> Update view and cache
8// -> Data the same? -> Do nothing
9
10// Step 3: User returns to the page
11// -> Repeat from Step 1 (instant display!)The user doesn't wait on the first return or on any later one, and the server still gets asked. So SWR doesn't save requests, it saves waiting time.
Simple Cache with Map
The simplest cache can be built with a Map object, a key-value dictionary. The key will be the URL, and the value the data together with a timestamp. The map lives outside the component, so it survives unmounting. Here's a custom hook that uses it:
1// Global cache - lives outside components
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; // Instant data from 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 // If we have cache - don't show spinner
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 // Save to cache with timestamp
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}The function passed to useState (lazy initialization) runs only on the first render and pulls the data out of the cache right away. The effect still always asks the server, and that is exactly the "revalidate" part. Everything else, AbortController, response.ok and the states, comes straight from the previous lessons.
TTL (Time To Live) - Cache Lifetime
TTL defines how long data in the cache is considered current. In our hook it decides whether to show the data from memory immediately on mount. The fresh background request always departs anyway:
1// TTL = 60000ms (1 minute)
2// Data older than 1 minute will be fetched again
3
4// Short TTL (5-30s) - frequently changing data
5// e.g. cryptocurrency prices, mission status
6const liveData = useFetchWithCache('/api/status', 5000);
7
8// Medium TTL (1-5 min) - moderately changing data
9// e.g. planet list, ship catalog
10const catalog = useFetchWithCache('/api/planets', 60000);
11
12// Long TTL (10-60 min) - rarely changing data
13// e.g. configuration, static lists
14const config = useFetchWithCache('/api/config', 600000);Choosing a TTL is a business decision, not a technical one. Ask yourself how much it would hurt the user to see data that is a minute old.
Cache Invalidation - Forcing a Refresh
Sometimes we know for sure that the cached data is outdated, for example right after adding a new mission. Then we remove the entry by hand, which is called invalidating the cache:
1// Simple invalidation - remove from cache
2function invalidateCache(url) {
3 cache.delete(url);
4}
5
6// After adding a new mission - invalidate the mission list
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 // Force refresh of mission list
15 invalidateCache('/api/missions');
16}The rule is simple: every mutation (POST, PUT, DELETE) that changes data on the server should invalidate the cache for that data.
Optimistic Updates - Updating Before the Response
Instead of waiting for the server, we can update the UI immediately and undo the change only if the server refuses. The key is remembering the previous state so we have something to go back to:
1function MissionList() {
2 const [missions, setMissions] = useState([]);
3
4 const deleteMission = async (id) => {
5 // 1. OPTIMISTICALLY remove from UI (instantly!)
6 const previousMissions = [...missions];
7 setMissions(prev => prev.filter(m => m.id !== id));
8
9 try {
10 // 2. Send DELETE request to server
11 const response = await fetch(`/api/missions/${id}`, {
12 method: 'DELETE',
13 });
14 // fetch doesn't throw for 4xx/5xx - check it ourselves
15 if (!response.ok) throw new Error(`HTTP ${response.status}`);
16 // Success - UI already updated!
17 } catch (error) {
18 // 3. ERROR - restore previous state (rollback)
19 setMissions(previousMissions);
20 alert('Failed to delete mission');
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 Delete
31 </button>
32 </li>
33 ))}
34 </ul>
35 );
36}The response.ok check is essential here: without it, a 500 response would never reach catch and the rollback would never run. I recommend optimistic updates for operations that almost always succeed, like a like or removing an item from a list.
TanStack Query -- Professional Caching
In large projects, instead of writing your own cache, we use the TanStack Query library (formerly React Query, currently version 5). It's like an advanced onboard computer that automatically manages all communication. We wrap the app in QueryClientProvider and fetch data with the useQuery hook:
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 - automatic 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, // Data fresh for 1 minute
27 gcTime: 300000, // Remove from cache after 5 minutes
28 });
29
30 if (isLoading) return <p>Scanning the galaxy...</p>;
31 if (error) return <p>Error: {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}There isn't a single useState or useEffect here: the library provides the loading and error states and the cache. One caveat from the docs: queryFn has to throw for the query to end up in the error state, and fetch() doesn't throw for 4xx/5xx. In production, add a res.ok check.
TanStack Query - Key Concepts
Below are the most important options in one place. queryKey identifies the data in the cache, staleTime says how long it stays fresh, and gcTime (called cacheTime in version 4) when unused data gets removed:
1// 1. queryKey - unique identifier for data in cache
2// Key change = new request
3const planets = useQuery({
4 queryKey: ['planets', page],
5 queryFn: () => fetchPlanets(page),
6});
7
8// 2. staleTime - how long data is fresh
9// staleTime: 0 - always refresh (default)
10// staleTime: 60000 - fresh for 1 minute
11// staleTime: Infinity - never refresh
12
13// 3. gcTime (garbage collection) - when to remove from cache
14// gcTime: 300000 - remove after 5 min of inactivity
15
16// 4. Automatic refetch:
17// - When window regains focus
18// - When network reconnects
19// - At regular intervals (refetchInterval)
20
21// 5. useMutation - write operations with invalidation
22const mutation = useMutation({
23 mutationFn: (newMission) =>
24 fetch('/api/missions', {
25 method: 'POST',
26 body: JSON.stringify(newMission),
27 }),
28 onSuccess: () => {
29 // After adding a mission - invalidate mission list cache
30 queryClient.invalidateQueries({
31 queryKey: ['missions']
32 });
33 },
34});The default staleTime is 0 and the default gcTime is 5 minutes. staleTime: Infinity only turns off automatic refreshing, manual invalidation still works. Inside a component you get the client with const queryClient = useQueryClient(), and in a real POST add the Content-Type header from the previous lesson. Notice that invalidateQueries is exactly our manual invalidation, just done for you.
Remember: the cache is the onboard computer's memory, it shows what you already know and checks in the background whether the galaxy has changed.
Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Global cache with 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 // Check 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: fetch in the background despite the 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('server (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>Planets</h3>
74 <div className="source-badge">{source || 'loading...'}</div>
75 <button className="btn" onClick={invalidate}>Invalidate 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>Characters</h3>
93 <div className="source-badge">{source || 'loading...'}</div>
94 <button className="btn" onClick={invalidate}>Invalidate 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>Cache State (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 ago</span>
119 </div>
120 ))}
121 <button className="btn danger" onClick={() => { cache.clear(); forceUpdate(n => n + 1); }}>Clear entire 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>Unmount the panels and mount them again - data appears instantly from cache!</p>
133 <button className="btn toggle" onClick={() => setShowPanels(!showPanels)}>
134 {showPanels ? 'Unmount panels' : 'Mount panels (data from 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;Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the name of the 'Stale-While-Revalidate' (SWR) pattern mean?
2. When should you 'invalidate' (delete/refresh) the cache in a React application?
Hands-on tasks in the game
- Vertical ordering
Arrange the steps of the Stale-While-Revalidate pattern in order:
- Code editor
Cache implementation with TTL
- Code editor
Optimistic updates with rollback
- Click in order
Click the elements in order to configure useQuery with TanStack Query: