Skip to main content

API handling and Caching

Caching API calls is important for performance and user experience. For example, if you fetch data in TAB_A, switch to TAB_B to fetch different data, and then quickly return to TAB_A, you typically don't need to refetch the data again if it hasn't changed. Instead, reuse the cached data and render the content. If necessary, refetch it in the background and only update the UI if the data has changed; otherwise, continue displaying the cached data.

Cached API Handler

The API handler should be a custom hook, that internally manages:

  1. Triggering API calls on component mount.
  2. Cache the API data and manage cache internally.
  3. Refetch the data in the background at configured intervals or when the cache expires or becomes invalid. And only update the managed state when the API data actually changes, to minimize component re-renders.
API handler usage
export const Posts = () => {
const { isLoading, isError, data: posts } = usePosts();

if (isLoading) return <div>Loading...</div>;
if (isError || !data) return <div>Something went wrong while loading posts.</div>;

return (
<div>
{posts.map(post => (
<div key={post.id}>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
))}
</div>
);
};
DON'T

If an API response is needed in more than 1 components which are deeply nested in the DOM tree. Do not call the api in the top parent component that doesn't even need that data, then pass that data to the children components as prop drilling.

Do not store store the data in browser storage then access them in the child components - this makes the component really hard to test.

Do not set the data in some global state from parent, and access them from children components. It's not clean.

Do not call the API in one of the child and set it in global state to access it from another child. This requires the chronology of component mounts to remain same. If the dependent component mounts first, then it wont find the data.

DO

Check if the data already exists in the cache, if not trigger the API call and set the appropriate states. All of this can be handled in the custom hook internally as lower level details.

The UI component only needs to call the hook and use the states: isLoading, isError, data etc. Each component that needs this data can use this custom hook, without being tightly coupled with any other component.

Implementing Cached API Handlers

Third Party Libraries

Utilize battle tested, community maintained, open source 3rd party libraries if you don't want to re-invent the wheel.

Own implementation

If you want a simple cached API handlers and don't need all the advanced features offered by the above 3rd party libraries, you can implement something as follows.

custom useCachedFetch hook

📁 useCachedFetch.js
import { useState, useEffect, useCallback, useRef } from 'react';

// Global cache store
const cache = new Map();

/**
* Custom hook for fetching data with caching capabilities
* @param {string} url - The URL to fetch from
* @param {Object} options - Configuration options
* @param {string} options.method - HTTP method (GET, POST, PUT, DELETE, etc.)
* @param {Object} options.headers - Request headers
* @param {any} options.body - Request body for POST/PUT requests
* @param {boolean} options.cache - Whether to cache the response (default: true)
* @param {boolean} options.preferCache - Whether to prefer cached data over fresh fetch (default: true)
* @param {number} options.cacheTimeout - Cache timeout in milliseconds (default: 5 minutes)
* @param {Array} options.dependencies - Dependencies that trigger refetch when changed
* @param {boolean} options.enabled - Whether the fetch should be enabled (default: true)
*/
export const useCachedFetch = (url, options = {}) => {
const {
method = 'GET',
headers = {},
body = null,
cache: shouldCache = true,
preferCache = true,
cacheTimeout = 5 * 60 * 1000, // 5 minutes
dependencies = [],
enabled = true,
} = options;

const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// Create a unique cache key based on URL, method, and body
const cacheKey = useRef();
const abortControllerRef = useRef();

// Generate cache key
const generateCacheKey = useCallback(() => {
const bodyStr = body ? JSON.stringify(body) : '';
const headersStr = JSON.stringify(headers);
return `${method}:${url}:${bodyStr}:${headersStr}`;
}, [url, method, body, headers]);

// Check if cached data is still valid
const isCacheValid = useCallback(
cachedItem => {
if (!cachedItem) return false;
const now = Date.now();
return now - cachedItem.timestamp < cacheTimeout;
},
[cacheTimeout],
);

// Get data from cache
const getCachedData = useCallback(
key => {
const cachedItem = cache.get(key);
if (cachedItem && isCacheValid(cachedItem)) {
return cachedItem.data;
}
return null;
},
[isCacheValid],
);

// Set data in cache
const setCachedData = useCallback(
(key, data) => {
if (shouldCache) {
cache.set(key, {
data,
timestamp: Date.now(),
});
}
},
[shouldCache],
);

// Clear cache entry
const clearCacheEntry = useCallback(key => {
cache.delete(key);
}, []);

// Main fetch function
const fetchData = useCallback(
async (forceRefresh = false) => {
if (!enabled || !url) return;

const key = generateCacheKey();
cacheKey.current = key;

// Check cache first if preferCache is true and not forcing refresh
if (preferCache && !forceRefresh && shouldCache) {
const cachedData = getCachedData(key);
if (cachedData) {
setData(cachedData);
setError(null);
return cachedData;
}
}

// Cancel previous request if it exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

// Create new abort controller
abortControllerRef.current = new AbortController();

try {
setLoading(true);
setError(null);

const fetchOptions = {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
signal: abortControllerRef.current.signal,
};

// Add body for non-GET requests
if (body && method !== 'GET') {
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
}

const response = await fetch(url, fetchOptions);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

// Try to parse as JSON, fallback to text
let responseData;
const contentType = response.headers.get('content-type');

if (contentType && contentType.includes('application/json')) {
responseData = await response.json();
} else {
responseData = await response.text();
}

// Cache the data
setCachedData(key, responseData);

setData(responseData);
setLoading(false);

return responseData;
} catch (err) {
// Don't set error if request was aborted
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
throw err;
}
},
[enabled, url, generateCacheKey, preferCache, shouldCache, getCachedData, setCachedData, method, headers, body],
);

// Refetch function that forces a fresh request
const refetch = useCallback(() => {
return fetchData(true);
}, [fetchData]);

// Clear cache for current request
const clearCache = useCallback(() => {
if (cacheKey.current) {
clearCacheEntry(cacheKey.current);
}
}, [clearCacheEntry]);

// Effect to trigger fetch on mount and dependency changes
useEffect(() => {
if (enabled) {
fetchData();
}

// Cleanup function
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [enabled, url, method, JSON.stringify(body), JSON.stringify(headers), ...dependencies]);

// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);

return {
data,
loading,
error,
refetch,
clearCache,
// Utility functions
isFromCache: preferCache && shouldCache && data && getCachedData(generateCacheKey()) === data,
};
};

// Utility function to clear all cache
export const clearAllCache = () => {
cache.clear();
};

// Utility function to get cache size
export const getCacheSize = () => {
return cache.size;
};

// Utility function to get all cache keys
export const getCacheKeys = () => {
return Array.from(cache.keys());
};

Usage Examples

Usage Example 1
const { data: users, loading, error, refetch, isFromCache } = useCachedFetch('https://jsonplaceholder.typicode.com/users');
Usage Example 2
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);

const {
data: posts,
loading,
error,
clearCache,
} = useCachedFetch(`https://jsonplaceholder.typicode.com/posts?userId=${selectedUserId}`, {
enabled: !!selectedUserId,
dependencies: [selectedUserId],
cacheTimeout: 2 * 60 * 1000, // 2 minutes
});
Usage Example 3
const [formData, setFormData] = useState({
title: '',
body: '',
userId: 1,
});
const [shouldSubmit, setShouldSubmit] = useState(false);

const {
data: response,
loading,
error,
} = useCachedFetch<Post>('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: formData,
cache: false, // Don't cache POST responses
enabled: shouldSubmit,
dependencies: [shouldSubmit],
});