Advanced React Hooks: Custom Hooks for API Fetching

Mobile Developer
September 15, 2024
Updated on January 25, 2025
0 MIN READ
#developer-tools#typescript#advanced#react

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:

  1. Track loading and error states.
  2. Fetch data when dependencies change.
  3. 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!

Share this article