Advanced React Hooks: Custom Hooks for API Fetching
Introduction
React Hooks have revolutionized how we write React components by enabling stateful logic without classes. While built-in hooks like useState
and useEffect
are powerful, custom hooks take this further by allowing developers to encapsulate and reuse logic across components. One of the most common use cases for custom hooks is API fetching—fetching, caching, and managing the state of remote data.
In this post, we'll explore how to build advanced custom hooks for API fetching, covering error handling, loading states, caching, and aborting requests. These techniques will help you write cleaner, more maintainable React applications while improving performance and user experience.
Why Use Custom Hooks for API Fetching?
API fetching is a repetitive task in modern web applications. Without custom hooks, you might find yourself duplicating the same useEffect
and useState
logic across multiple components. This leads to:
- Code duplication – The same fetching logic appears in multiple places.
- Inconsistent error handling – Different components may handle errors differently.
- Poor performance – Unoptimized fetching can lead to unnecessary re-renders or memory leaks.
Custom hooks solve these problems by centralizing API logic while keeping components clean and focused on rendering. Let’s see how we can build a basic custom hook for fetching data.
Building a Basic API Fetching Hook
A minimal custom hook for API fetching should:
- Track loading and error states.
- Fetch data when dependencies change.
- Clean up pending requests to avoid memory leaks.
Here’s a basic implementation:
import { useState, useEffect } from 'react'; function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); setData(result); } catch (err) { setError(err); } finally { setLoading(false); } }; fetchData(); }, [url]); return { data, loading, error }; }
Usage in a component:
function UserProfile({ userId }) { const { data, loading, error } = useFetch(`/api/users/${userId}`); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ); }
This hook simplifies data fetching, but we can improve it further.
Advanced Enhancements: Aborting Requests and Caching
1. Aborting Requests with AbortController
When a component unmounts or dependencies change, pending requests should be canceled to prevent memory leaks and race conditions. We can use AbortController
to achieve this.
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const abortController = new AbortController(); const fetchData = async () => { try { const response = await fetch(url, { signal: abortController.signal }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const result = await response.json(); setData(result); } catch (err) { if (err.name !== 'AbortError') { setError(err); } } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; fetchData(); return () => abortController.abort(); }, [url]); return { data, loading, error }; }
2. Adding a Simple Cache
To avoid redundant API calls, we can cache responses using useRef
or an external library like react-query
. Here’s a simple in-memory cache:
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const cache = useRef({}); useEffect(() => { const abortController = new AbortController(); const fetchData = async () => { if (cache.current[url]) { setData(cache.current[url]); setLoading(false); return; } try { const response = await fetch(url, { signal: abortController.signal }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const result = await response.json(); cache.current[url] = result; setData(result); } catch (err) { if (err.name !== 'AbortError') { setError(err); } } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; fetchData(); return () => abortController.abort(); }, [url]); return { data, loading, error }; }
Handling Pagination and Refetching
For dynamic data (e.g., paginated lists or real-time updates), we can extend our hook to support refetching and pagination.
function usePaginatedFetch(url, initialPage = 1) { const [data, setData] = useState([]); const [page, setPage] = useState(initialPage); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(true); const fetchData = async (pageNum) => { setLoading(true); try { const response = await fetch(`${url}?page=${pageNum}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const result = await response.json(); setData(prev => [...prev, ...result.items]); setHasMore(result.hasMore); } catch (err) { setError(err); } finally { setLoading(false); } }; const loadMore = () => { if (hasMore && !loading) { setPage(prev => prev + 1); fetchData(page + 1); } }; useEffect(() => { fetchData(page); }, []); return { data, loading, error, loadMore, hasMore }; }
Usage:
function PostList() { const { data, loading, error, loadMore, hasMore } = usePaginatedFetch('/api/posts'); return ( <div> {data.map(post => ( <Post key={post.id} post={post} /> ))} {loading && <div>Loading more...</div>} {hasMore && !loading && ( <button onClick={loadMore}>Load More</button> )} </div> ); }
Conclusion
Custom hooks for API fetching streamline your React applications by encapsulating complex logic into reusable functions. By enhancing these hooks with features like request cancellation, caching, and pagination, you can build more performant and maintainable applications.
While our examples cover the basics, libraries like react-query
and swr
offer even more advanced features (e.g., background refetching, optimistic updates). However, understanding how to build these hooks from scratch gives you greater control and insight into your application’s data flow.
Start integrating these patterns into your projects, and you’ll see cleaner components, fewer bugs, and a better developer experience. Happy coding!