When building a powerful useFetch hook, the goal is to make API calls efficient, cancelable, retryable, and cache-aware so that we don’t hit the network unnecessarily or face state update issues after unmount.
🧠 Key Features to Include
- Caching – Avoid duplicate calls if the data is already fetched.
- Retries – Handle flaky network calls gracefully.
- Cancellation – Abort requests when components unmount or a new request starts.
- Race Condition Handling – Ensure only the latest response updates the state.
🧾 Implementation
import { useEffect, useRef, useState } from "react";
const cache = new Map();
export function useFetch(url, options = {}, { retries = 2 } = {}) {
const [data, setData] = useState(cache.get(url) || null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(!cache.has(url));
const abortControllerRef = useRef(null);
const requestIdRef = useRef(0); // For race condition handling
useEffect(() => {
if (cache.has(url)) return; // Return cached data immediately
let attempt = 0;
const id = ++requestIdRef.current;
abortControllerRef.current = new AbortController();
const fetchData = async () => {
setLoading(true);
while (attempt <= retries) {
try {
const res = await fetch(url, { ...options, signal: abortControllerRef.current.signal });
if (!res.ok) throw new Error(`Error: ${res.status}`);
const json = await res.json();
// Only update state if this request is still the latest
if (requestIdRef.current === id) {
cache.set(url, json);
setData(json);
setError(null);
setLoading(false);
}
break;
} catch (err) {
if (err.name === "AbortError") return;
attempt++;
if (attempt > retries) {
setError(err);
setLoading(false);
}
}
}
};
fetchData();
return () => {
abortControllerRef.current?.abort(); // Cancel on unmount or re-render
};
}, [url]);
return { data, error, loading, refetch: () => cache.delete(url) };
}
🧭 How It Works
- Caching:
cacheMap stores successful responses, so the next call with the same URL doesn’t refetch. - Retries: Retries the API call up to the given limit on failure.
- Cancellation: Uses
AbortControllerto stop in-flight requests if the component unmounts or URL changes. - Race Conditions:
requestIdRefensures that only the latest request can update the state, avoiding stale data.
🧪 Usage Example
function UsersList() {
const { data, error, loading } = useFetch("https://jsonplaceholder.typicode.com/users");
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong: {error.message}</p>;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
✨ Why This Approach Works
- Fast UI due to caching.
- Safe cleanup with cancellation.
- No stale state even when requests overlap.
- Easy to reuse across multiple components.