L9: Add Caching to useFetch
Implement in-memory caching to prevent duplicate API requests
Enhance our useFetch hook with caching to eliminate duplicate API requests and improve performance!
What You'll Learn
- Implement in-memory caching
- Cache invalidation strategies
- Prevent duplicate requests
- Share cached data between components
- Configure cache behavior
- Measure cache effectiveness
The Problem: Duplicate Requests
Currently, every component that uses useFetch makes its own request:
// Component 1
function ListingDetailsPage() {
const { data } = useFetch('/listings/123'); // Request 1
return <div>{data?.title}</div>;
}
// Component 2 (same listing!)
function RelatedListings() {
const { data } = useFetch('/listings/123'); // Request 2 (duplicate!)
return <div>{data?.title}</div>;
}Problems:
- Wastes bandwidth
- Slower load times
- Unnecessary API load
- Poor user experience
Performance impact: In development (React.StrictMode), components mount twice, causing double requests. Caching solves this!
Solution: In-Memory Cache
We'll create a cache object that stores API responses:
// Cache structure
const cache = {
'/listings': { data: [...], timestamp: 1234567890 },
'/listings/123': { data: {...}, timestamp: 1234567891 }
};How it works:
- Check if URL is in cache
- If yes → Return cached data (instant! ⚡)
- If no → Fetch from API, store in cache
- Optionally expire old cache entries
Implementing the Cache
Create cache object
import { useState, useEffect } from 'react';
import api from '@/api';
// In-memory cache outside component
const cache = {};
// Cache configuration
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const ENABLE_CACHE = true;
export function useFetch(url) {
// ... hook implementation
}Cache is outside the hook so it's shared across all components!
Check cache before fetching
export function useFetch(url) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
// Check cache first
if (ENABLE_CACHE && cache[url]) {
const cached = cache[url];
const age = Date.now() - cached.timestamp;
// Use cached data if fresh
if (age < CACHE_DURATION) {
setData(cached.data);
setIsLoading(false);
return; // Skip fetch!
}
}
// Cache miss or expired - fetch from API
try {
setIsLoading(true);
setError(null);
const response = await api.get(url, {
signal: controller.signal
});
// Store in cache
cache[url] = {
data: response.data,
timestamp: Date.now()
};
setData(response.data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url]);
return { data, isLoading, error };
}Flow:
- Check if
cache[url]exists - Check if cache is fresh (< 5 minutes)
- If yes → Return cached data, skip fetch
- If no → Fetch and update cache
Add cache inspection
export function useFetch(url) {
// ... existing code
useEffect(() => {
const fetchData = async () => {
if (ENABLE_CACHE && cache[url]) {
console.log(`Cache HIT: ${url}`);
// ... return cached data
} else {
console.log(`Cache MISS: ${url}`);
// ... fetch from API
}
};
fetchData();
}, [url]);
return { data, isLoading, error };
}Check browser console to see cache hits!
Complete Cached Hook
import { useState, useEffect } from 'react';
import api from '@/api';
// Cache configuration
const cache = {};
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const ENABLE_CACHE = true;
export function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const {
cacheTime = CACHE_DURATION,
enableCache = ENABLE_CACHE
} = options;
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
// Check cache
if (enableCache && cache[url]) {
const cached = cache[url];
const age = Date.now() - cached.timestamp;
if (age < cacheTime) {
console.log(`✅ Cache HIT: ${url} (age: ${Math.round(age/1000)}s)`);
setData(cached.data);
setIsLoading(false);
return;
} else {
console.log(`⏰ Cache EXPIRED: ${url}`);
}
} else if (enableCache) {
console.log(`❌ Cache MISS: ${url}`);
}
// Fetch from API
try {
setIsLoading(true);
setError(null);
const response = await api.get(url, {
signal: controller.signal
});
// Update cache
if (enableCache) {
cache[url] = {
data: response.data,
timestamp: Date.now()
};
console.log(`💾 Cached: ${url}`);
}
setData(response.data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url, cacheTime, enableCache]);
return { data, isLoading, error };
}
// Utility functions
export function clearCache(url) {
if (url) {
delete cache[url];
console.log(`🗑️ Cleared cache: ${url}`);
} else {
Object.keys(cache).forEach(key => delete cache[key]);
console.log('🗑️ Cleared all cache');
}
}
export function getCacheStats() {
const keys = Object.keys(cache);
const stats = keys.map(key => ({
url: key,
age: Math.round((Date.now() - cache[key].timestamp) / 1000),
size: JSON.stringify(cache[key].data).length
}));
console.table(stats);
return stats;
}Usage Examples
// Uses default 5-minute cache
function HomePage() {
const { data: listings } = useFetch('/listings');
return <ListingList listings={listings} />;
}
// Second component uses cached data!
function FeaturedListings() {
const { data: listings } = useFetch('/listings'); // Cache HIT!
return <FeaturedList listings={listings?.slice(0, 3)} />;
}Result: Only one API request, two components get data instantly!
// Cache for 1 minute (data changes frequently)
function RealTimeStats() {
const { data: stats } = useFetch('/stats', {
cacheTime: 60 * 1000 // 1 minute
});
return <Stats data={stats} />;
}
// Cache for 1 hour (data rarely changes)
function AppConfig() {
const { data: config } = useFetch('/config', {
cacheTime: 60 * 60 * 1000 // 1 hour
});
return <Config data={config} />;
}// Always fetch fresh data (for critical data)
function BankBalance() {
const { data: balance } = useFetch('/balance', {
enableCache: false // Always fresh
});
return <div>${balance}</div>;
}import { useFetch, clearCache } from '@/hooks/useFetch';
function DataManager() {
const { data, isLoading } = useFetch('/data');
const handleRefresh = () => {
clearCache('/data'); // Clear specific URL
window.location.reload(); // Reload to refetch
};
const handleClearAll = () => {
clearCache(); // Clear all cache
window.location.reload();
};
return (
<>
<button onClick={handleRefresh}>Refresh Data</button>
<button onClick={handleClearAll}>Clear All Cache</button>
<div>{data}</div>
</>
);
}Performance Impact
Scenario: User navigates between pages
- HomePage → Fetch /listings (500ms)
- Navigate to details → Fetch /listings/123 (300ms)
- Back to HomePage → Fetch /listings again (500ms)
- Navigate to details again → Fetch /listings/123 again (300ms)
Total: 1600ms of waiting, 4 API requests
Scenario: Same navigation
- HomePage → Fetch /listings (500ms) → Cache it
- Navigate to details → Fetch /listings/123 (300ms) → Cache it
- Back to HomePage → Use cached /listings (0ms) ⚡
- Navigate to details again → Use cached /listings/123 (0ms) ⚡
Total: 800ms of waiting, 2 API requests
Performance improvement: 50% faster, 50% fewer requests! 🚀
Advanced: Stale-While-Revalidate
Show cached data immediately, then update in background:
export function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isStale, setIsStale] = useState(false);
const { staleWhileRevalidate = false } = options;
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
// Check cache
if (cache[url]) {
const cached = cache[url];
const age = Date.now() - cached.timestamp;
// Show cached data immediately
setData(cached.data);
setIsLoading(false);
if (age < CACHE_DURATION) {
if (!staleWhileRevalidate) {
return; // Fresh, don't refetch
}
}
// Mark as stale, refetch in background
setIsStale(true);
}
// Fetch (either no cache, or revalidating)
try {
if (!cache[url]) {
setIsLoading(true);
}
const response = await api.get(url, {
signal: controller.signal
});
cache[url] = {
data: response.data,
timestamp: Date.now()
};
setData(response.data);
setIsStale(false);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url, staleWhileRevalidate]);
return { data, isLoading, error, isStale };
}
// Usage
function Component() {
const { data, isStale } = useFetch('/data', {
staleWhileRevalidate: true
});
return (
<div>
{isStale && <span>Updating...</span>}
<div>{data}</div>
</div>
);
}Benefits:
- Instant UI (show cached data)
- Always up-to-date (background refetch)
- Best user experience!
Cache Invalidation Strategies
Monitoring Cache Performance
// Add to useFetch for metrics
let cacheHits = 0;
let cacheMisses = 0;
export function getCacheMetrics() {
const total = cacheHits + cacheMisses;
const hitRate = total ? (cacheHits / total * 100).toFixed(1) : 0;
return {
hits: cacheHits,
misses: cacheMisses,
total,
hitRate: `${hitRate}%`
};
}
// View in console
console.log('Cache metrics:', getCacheMetrics());
// { hits: 45, misses: 12, total: 57, hitRate: "78.9%" }Best Practices
Choose appropriate cache duration
// Frequently changing data - short cache
useFetch('/live-stats', { cacheTime: 10000 }); // 10 seconds
// Rarely changing data - long cache
useFetch('/categories', { cacheTime: 3600000 }); // 1 hour
// Static data - very long cache
useFetch('/constants', { cacheTime: 86400000 }); // 24 hoursClear cache on mutations
async function createListing(data) {
await api.post('/listings', data);
clearCache('/listings'); // Refetch will get new listing
}
async function updateListing(id, changes) {
await api.put(`/listings/${id}`, changes);
clearCache(`/listings/${id}`); // Specific URL
clearCache('/listings'); // List page too
}Monitor cache effectiveness
// Log cache metrics periodically
useEffect(() => {
const interval = setInterval(() => {
console.log('Cache stats:', getCacheMetrics());
getCacheStats(); // Shows table
}, 60000); // Every minute
return () => clearInterval(interval);
}, []);Target: 70%+ cache hit rate
What's Next?
In Lesson 10, we'll review all Module 5 concepts - custom hooks, useMemo, useCallback, React.memo, profiling, and caching. We'll see how they all work together! 🚀
Summary
- ✅ Implemented in-memory caching in useFetch
- ✅ Eliminates duplicate API requests
- ✅ 50% faster page navigation
- ✅ Configurable cache duration
- ✅ Manual cache invalidation
- ✅ Stale-while-revalidate pattern
- ✅ Cache performance monitoring
Key concept: Caching prevents duplicate work. It's one of the most effective optimizations you can make!