Code To Learn logo

Code To Learn

M5: Hooks & Performance

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:

  1. Check if URL is in cache
  2. If yes → Return cached data (instant! ⚡)
  3. If no → Fetch from API, store in cache
  4. Optionally expire old cache entries

Implementing the Cache

Create cache object

src/hooks/useFetch.js
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

src/hooks/useFetch.js
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:

  1. Check if cache[url] exists
  2. Check if cache is fresh (< 5 minutes)
  3. If yes → Return cached data, skip fetch
  4. If no → Fetch and update cache

Add cache inspection

src/hooks/useFetch.js
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

src/hooks/useFetch.js
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

  1. HomePage → Fetch /listings (500ms)
  2. Navigate to details → Fetch /listings/123 (300ms)
  3. Back to HomePage → Fetch /listings again (500ms)
  4. Navigate to details again → Fetch /listings/123 again (300ms)

Total: 1600ms of waiting, 4 API requests

Scenario: Same navigation

  1. HomePage → Fetch /listings (500ms) → Cache it
  2. Navigate to details → Fetch /listings/123 (300ms) → Cache it
  3. Back to HomePage → Use cached /listings (0ms) ⚡
  4. 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 hours

Clear 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!