useFetch hook with abort controller
typescriptA custom React hook that fetches data with automatic request cancellation on unmount and race condition prevention using AbortController.
Code
import { useState, useEffect, useRef, useCallback } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
interface UseFetchOptions extends Omit<RequestInit, 'signal'> {
enabled?: boolean;
}
interface UseFetchReturn<T> extends FetchState<T> {
refetch: () => Promise<void>;
}
function useFetch<T = unknown>(
url: string | null,
options: UseFetchOptions = {}
): UseFetchReturn<T> {
const { enabled = true, ...fetchOptions } = options;
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: false,
error: null,
});
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef<number>(0);
const fetchData = useCallback(async () => {
if (!url) {
setState({ data: null, loading: false, error: null });
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const currentRequestId = ++requestIdRef.current;
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url, {
...fetchOptions,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
if (currentRequestId === requestIdRef.current) {
setState({ data, loading: false, error: null });
}
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
return;
}
if (currentRequestId === requestIdRef.current) {
setState({ data: null, loading: false, error });
}
}
}
}, [url, JSON.stringify(fetchOptions)]);
useEffect(() => {
if (!enabled) {
return;
}
fetchData();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData, enabled]);
return {
...state,
refetch: fetchData,
};
}
export { useFetch };
export type { FetchState, UseFetchOptions, UseFetchReturn };
// Usage example:
// interface User {
// id: number;
// name: string;
// email: string;
// }
//
// function UserProfile({ userId }: { userId: string }) {
// const { data, loading, error, refetch } = useFetch<User>(
// `https://api.example.com/users/${userId}`,
// { enabled: Boolean(userId) }
// );
//
// if (loading) return <div>Loading...</div>;
// if (error) return <div>Error: {error.message}</div>;
// if (!data) return <div>No user found</div>;
//
// return (
// <div>
// <h1>{data.name}</h1>
// <p>{data.email}</p>
// <button onClick={refetch}>Refresh</button>
// </div>
// );
// }How It Works
This useFetch hook implements a robust data fetching pattern that solves several common problems in React applications. The hook manages three pieces of state: the fetched data, a loading indicator, and any error that occurred. It uses TypeScript generics to provide type safety for the response data, allowing consumers to specify the expected shape of the API response.
The AbortController is the cornerstone of this implementation. Each time a fetch begins, we create a new AbortController and pass its signal to the fetch request. When the component unmounts, the cleanup function in useEffect calls abort(), which cancels any in-flight request. This prevents the common React warning about updating state on unmounted components and avoids memory leaks. The hook also aborts previous requests when a new one starts, ensuring only the latest request's response updates the state.
Race conditions are handled through a request ID mechanism. Each fetch increments a counter stored in a ref, and before updating state with the response, we verify the current request ID matches. This prevents a slower early request from overwriting the results of a faster subsequent request—a common issue when URL parameters change rapidly, such as in search-as-you-type implementations.
The hook accepts an optional enabled flag, which is useful for conditional fetching scenarios where you might not have all required parameters yet. When disabled, the hook skips the fetch entirely. The refetch function is exposed to allow manual re-fetching, useful for refresh buttons or after mutations. Note that we serialize fetchOptions with JSON.stringify in the dependency array—while not perfect for all cases, it handles the common scenario of passing object literals as options.
Use this hook for straightforward GET requests where you want automatic lifecycle management. For more complex scenarios like caching, deduplication, or background refetching, consider libraries like TanStack Query or SWR. Also note this hook expects JSON responses; you'd need to modify it for other content types like text or blobs.