useFetch Custom Hook: Loading, Errors, and Cancellation
A basic useFetch hook is useful, but production applications need robust error handling, loading states, and request cancellation. This guide enhances your custom hook by adding loading and error state management, and implements request cancellation using the AbortController API to prevent memory leaks and race conditions when components unmount or props change during a fetch.
Why Your useFetch Hook Needs Loading, Errors, and Cancellation
What problems occur without proper state management and cancellation?
A naive useFetch hook that only returns data has three critical gaps:
- No loading indicator — Users don't know if data is fetching or if the component is stuck. Loading states signal that the app is working.
- No error handling — Network failures, server errors, and bad responses crash the app or display no feedback. Users don't understand what went wrong.
- Memory leaks from pending requests — If a component unmounts while a fetch is in flight, the response arrives after the component is gone. React warns about "state updates on unmounted components," and the fetch request wastes bandwidth in the background.
A race condition is a more subtle problem: if a user changes a filter or postId quickly, multiple fetch requests start in parallel. The oldest request might arrive last and overwrite newer data, causing the UI to display stale information.
Solution: Return { data, loading, error } from your hook, and use AbortController to cancel in-flight requests when the component unmounts or dependencies change.
Understanding AbortController for Request Cancellation
How AbortController lets you stop fetch requests
AbortController is a web API for canceling fetch requests and other async operations. Each controller has a signal that you pass to fetch. When you call controller.abort(), it immediately cancels the fetch, and the promise rejects with an AbortError.
Basic example:
const controller = new AbortController();
const signal = controller.signal;
// Start fetch with signal
fetch(url, { signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request cancelled');
} else {
console.error('Fetch failed:', err);
}
});
// Cancel the fetch
controller.abort(); // Stops the request immediately
When you abort, the fetch promise rejects, and you can distinguish abort errors from network errors by checking error.name === 'AbortError'.
Building a Production-Ready useFetch Hook
Implementation with loading, error states, and cancellation
Here's the complete hook:
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Create a new AbortController for this fetch
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
setLoading(true);
setError(null); // Clear previous errors
try {
const response = await fetch(url, { signal });
// Check HTTP status
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
setData(json);
} catch (err) {
// Distinguish abort from actual errors
if (err.name === 'AbortError') {
console.log('Fetch cancelled (component unmounted or dependency changed)');
} else {
setError(err);
}
} finally {
setLoading(false);
}
};
fetchData();
// Cleanup: cancel fetch if component unmounts or url changes
return () => {
controller.abort();
};
}, [url]); // Re-run if url changes
return { data, loading, error };
}
export default useFetch;
Code walkthrough:
new AbortController()— Create a controller for this fetch. Each fetch gets its own controller.fetch(url, { signal })— Pass the signal to fetch so it's linked to the controller.- Error handling — Check
error.name === 'AbortError'to distinguish cancellations from real errors. finally— Always setloading = false, even on abort, so the loading state resolves.- Cleanup function — Return a function that calls
controller.abort(). React calls it when the component unmounts or theurlchanges. - Dependency array
[url]— Re-run the effect if the URL changes, automatically cancelling the previous request.
Using the hook in a component
import React from 'react';
import useFetch from './useFetch';
function Post({ postId }) {
const { data: post, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`
);
if (loading) {
return <div>Loading post...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
);
}
export default Post;
When postId changes:
- The component re-renders and calls
useFetchwith a new URL. - The old effect's cleanup function runs, calling
controller.abort()on the previous fetch. - The old fetch is cancelled immediately.
- The new effect runs, creating a new controller and starting a fresh fetch.
- The UI displays "Loading..." while the new data arrives.
This prevents race conditions (old data overwriting new data) and memory leaks (requests completing for unmounted components).
Advanced: Preventing State Updates on Unmounted Components
Alternative pattern when using libraries like axios or other async patterns
AbortController is the standard, but if you're using libraries that don't support it, the isMounted flag pattern still works:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true; // Track if component is mounted
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setData(json);
}
} catch (err) {
if (isMounted) {
setError(err);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
// Cleanup: mark as unmounted
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
Trade-off: The request still completes in the background (wasting bandwidth), but state updates are prevented. AbortController is superior because it cancels the request entirely.
Handling Common Scenarios
Scenario: User changes filter while data is loading
function FilteredPosts({ category }) {
const { data: posts, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/posts?categoryId=${category}`
);
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
When the user selects a new category, the category prop changes, the URL changes, the old fetch is cancelled, and a new one starts. The UI always shows the correct data with no race conditions.
Scenario: Navigation away cancels in-flight requests
function App() {
const [currentPage, setCurrentPage] = useState('home');
const { data, loading, error } = useFetch(
currentPage === 'posts' ? '/api/posts' : null
);
return (
<div>
<button onClick={() => setCurrentPage('home')}>Home</button>
<button onClick={() => setCurrentPage('posts')}>Posts</button>
{currentPage === 'posts' && (
loading ? <div>Loading...</div> : <PostList posts={data} />
)}
</div>
);
}
When the user clicks "Home," the currentPage changes, the URL becomes null, the effect's cleanup cancels the fetch, and the UI returns to the home page. If the user had started the fetch but navigated away, it's immediately cancelled.
Performance Considerations
When does the hook refetch?
The effect re-runs only when url changes (dependency array [url]). If other props or state change without affecting the URL, no refetch occurs. This is efficient.
To refetch manually, pass additional dependencies or add a refetch function:
function useFetch(url, refetchTrigger) {
// ... hook code ...
useEffect(() => {
// ... fetch code ...
}, [url, refetchTrigger]); // Re-run if url OR refetchTrigger changes
}
// In component:
const [refetch, setRefetch] = useState(0);
const { data, loading, error } = useFetch(url, refetch);
<button onClick={() => setRefetch(prev => prev + 1)}>
Refetch Data
</button>
Every time the button is clicked, refetch increments, triggering the effect to run again.
Key Takeaways
- Loading, error, and data states are essential for user feedback and debugging.
AbortControlleris the modern standard for cancelling fetch requests, preventing memory leaks and race conditions.- Cleanup functions ensure requests are cancelled when components unmount or dependencies change.
- Race conditions are prevented because old requests are cancelled when the URL changes, ensuring new data always takes priority.
- Always check
error.name === 'AbortError'to distinguish intentional cancellations from network failures. - The dependency array controls refetching — only include values that should trigger new fetches.
Frequently Asked Questions
What's the difference between AbortController and the isMounted flag pattern?
AbortController cancels the HTTP request entirely, saving bandwidth and server resources. The isMounted flag prevents state updates but lets the request complete. AbortController is more efficient and is the modern standard. Use it unless you're forced to use a library that doesn't support abort signals.
Does AbortController work with all fetch requests?
Yes. Any fetch() call accepts a signal option. Most modern async libraries (axios, ky, undici) also support abort signals. It's a web standard.
What if I need to cancel a fetch manually (not just on unmount)?
Expose the abort controller or create a refetch function:
function useFetch(url) {
const controllerRef = useRef(null);
// ... rest of hook ...
const cancel = () => controllerRef.current?.abort();
return { data, loading, error, cancel };
}
Then in your component:
const { data, cancel } = useFetch(url);
<button onClick={cancel}>Cancel Fetch</button>
Can I combine useFetch with useCallback to optimize performance?
Yes. If you're passing the URL as a computed value, wrap it in useCallback to ensure the hook only refetches when the URL logically changes, not when the function reference changes:
const url = useCallback(() => {
return `/api/posts?category=${category}`;
}, [category]);
How do I handle retries with this hook?
Add a retry counter to the dependency array:
const [retryCount, setRetryCount] = useState(0);
const { data, loading, error } = useFetch(url, retryCount);
if (error && retryCount < 3) {
setTimeout(() => setRetryCount(r => r + 1), 1000); // Retry after 1s
}
Each increment of retryCount triggers the effect to refetch.