Why Custom Fetch Hooks Matter
The evolution of React data fetching has followed a predictable trajectory that most development teams experience. Early React applications typically handled data fetching directly in components using lifecycle methods, quickly realizing that this approach led to significant code duplication.
A custom fetch hook addresses these pain points by centralizing all data-related concerns into a single, reusable abstraction. When you create a hook like useFetch, you're not just encapsulating the fetch call itself--you're building a contract that ensures consistent behavior across every component that needs data.
Beyond code organization, custom fetch hooks enable sophisticated caching strategies that would be impractical to implement on a per-component basis. The result is cleaner component code that focuses on presentation rather than data logistics.
In modern web development, data fetching is one of the most critical operations that directly impacts user experience and application performance. Every time a user interacts with your application--whether they're viewing a product catalog, loading user profiles, or fetching dashboard metrics--you're making network requests that determine how responsive and fluid your application feels. While React's component model provides excellent mechanisms for managing UI state, the same cannot be said for server state--the data that lives on your backend and needs to be synchronized with your frontend.
For teams building Next.js applications where performance and SEO are built-in requirements, or complex single-page applications with multiple data dependencies, understanding how to implement effective caching in custom hooks will make applications faster, more reliable, and significantly easier to maintain.
Essential features every production-ready fetch hook should include
Request Deduplication
Prevent redundant network requests when multiple components request the same data simultaneously.
Caching Layer
Store fetched data for immediate access on subsequent requests without network overhead.
Error Handling
Comprehensive error states that components can consume for meaningful user feedback.
Loading States
Track request progress to enable loading indicators and skeleton screens.
AbortController
Cancel pending requests when components unmount to prevent memory leaks.
Retry Logic
Automatic retry for transient failures with configurable attempts and backoff.
1function useFetch<T>(url: string) {2 const [data, setData] = useState<T | null>(null);3 const [loading, setLoading] = useState(true);4 const [error, setError] = useState<Error | null>(null);5 6 useEffect(() => {7 async function fetchData() {8 try {9 setLoading(true);10 const response = await fetch(url);11 if (!response.ok) {12 throw new Error(`HTTP error! status: ${response.status}`);13 }14 const json = await response.json();15 setData(json);16 setError(null);17 } catch (e) {18 setError(e instanceof Error ? e : new Error('An error occurred'));19 setData(null);20 } finally {21 setLoading(false);22 }23 }24 25 fetchData();26 }, [url]);27 28 return { data, loading, error };29}Cache Invalidation Strategies
Perhaps no aspect of caching is more critical--and more frequently mishandled--than cache invalidation. Caching is easy: you store data when you fetch it and return the stored data on subsequent requests. Invalidation requires understanding when cached data might be stale and deciding what to do about it.
Time-Based Invalidation
The simplest staleness indicator is time. Data fetched at 3:00 PM should be considered potentially stale by 3:01 PM if the underlying information changes frequently:
const STALE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
function isDataStale(timestamp: number): boolean {
return Date.now() - timestamp > STALE_THRESHOLD;
}
Tag-Based Invalidation
For complex data relationships, tag-based invalidation provides flexibility. When a mutation affects multiple resources, you invalidate by tag rather than by specific URL:
const tagIndex = new Map<string, Set<string>>();
function invalidateTags(...tags: string[]) {
for (const tag of tags) {
const urls = tagIndex.get(tag);
if (urls) {
for (const url of urls) {
cache.delete(url);
}
tagIndex.delete(tag);
}
}
}
Optimistic Updates
The optimistic update pattern combines immediate UI updates with potential cache invalidation. When a user performs an action, the UI is immediately updated while the actual API request happens in the background. This pattern is commonly used in libraries like TanStack Query and provides instant user feedback while ensuring data consistency.
Understanding cache invalidation requires understanding the different ways data can become stale. The simplest case is time-based staleness handled through time-to-live (TTL) settings. More complex scenarios involve data relationships and mutations--when a user updates their profile, any cached profile data becomes stale, along with cached lists that include their profile and activity feeds that show their actions.
1function useStaleFetch<T>(url: string, staleTime = 60000) {2 const [data, setData] = useState<T | null>(null);3 const [isStale, setIsStale] = useState(false);4 const cacheRef = useRef<Map<string, CacheEntry<T>>>(5 new Map()6 );7 8 useEffect(() => {9 async function fetchData() {10 const now = Date.now();11 const cached = cacheRef.current.get(url);12 13 // Return cached data if available and not stale14 if (cached && (now - cached.timestamp) < staleTime) {15 setData(cached.data);16 setIsStale(false);17 return;18 }19 20 // If cached but stale, mark as stale and fetch in background21 if (cached) {22 setData(cached.data);23 setIsStale(true);24 } else {25 setData(null);26 setIsStale(false);27 }28 29 // Always fetch fresh data30 try {31 const response = await fetch(url);32 const freshData = await response.json();33 const entry = { data: freshData, timestamp: now };34 cacheRef.current.set(url, entry);35 setData(freshData);36 setIsStale(false);37 } catch (error) {38 if (cached) setIsStale(true);39 }40 }41 42 fetchData();43 }, [url, staleTime]);44 45 return { data, isStale };46}Custom Hooks vs. Libraries
When to Build Custom Hooks
Building custom hooks makes sense when you have highly specialized requirements that existing libraries don't address well, when minimizing bundle size is paramount and you only need basic functionality, or when the team wants full control over behavior.
When to Use Libraries
Using libraries like SWR or TanStack Query makes sense for most production applications:
| Feature | SWR | TanStack Query |
|---|---|---|
| Bundle Size | 5.3KB | 16.2KB |
| DevTools | Community | Official |
| API Style | Arguments | Object config |
| Ecosystem | Vercel-focused | Multi-framework |
The SWR library (stale-while-revalidate) is developed by Vercel and integrates seamlessly with Next.js applications. TanStack Query (formerly React Query) offers comprehensive features including DevTools for debugging and a larger plugin ecosystem.
For most teams, using an established library is recommended--the engineering cost of building and maintaining a production-grade data fetching solution almost always exceeds the cost of importing a library. When building AI-powered applications that require sophisticated data management, these libraries provide battle-tested foundations that accelerate development.
Key optimizations for production-ready fetch hooks
Request Deduplication
Implement a request registry to prevent duplicate requests when multiple components request the same URL.
Proper Dependencies
Include only necessary dependencies in useEffect to avoid excessive refetching.
Memoization
Use useMemo for expensive data transformations to avoid recalculation on every render.
AbortController
Cancel pending requests on unmount to prevent memory leaks and state updates on unmounted components.
LRU Caching
Implement least-recently-used eviction to bound memory consumption.
Monitoring
Add logging and metrics to understand fetch patterns and diagnose issues.