useApiResource as a pattern, before TanStack Query enters the picture
Loading spinner, error message, data: this triangle of state gets typed out almost identically in every component that loads data from an API. A custom hook bundles that pattern in one place before it silently spreads to every component that needs it, and at the same time shows exactly where a hand-rolled solution ends and a library like TanStack Query begins.
Table of Contents
- 1. Why data-fetching logic repeats itself across components
- 2. The basic structure of useApiResource
- 3. Avoiding race conditions when the URL changes quickly
- 4. Variants for POST requests and dependent queries
- 5. Where a hand-rolled hook hits its limits
- 6. When TanStack Query is clearly the better choice
- 7. Testing custom hooks in isolation
- 8. Type safety with generic hooks
- 9. Making the decision in practice
- 10. Summary
- 11. FAQ
1. Why data-fetching logic repeats itself across components
Almost every component that loads data from an API needs the same three states: a loading state while the request is in flight, an error state in case it fails, and the actual data once the response arrives. Without a shared abstraction, this triangle of three useState calls and a useEffect ends up freshly retyped in every single component that loads anything from a server, with small, unintentional differences between the copies that accumulate over time.
Those differences are the real problem, not the typing effort. One component forgets to reset the error state on a new request, another calls setState after the component has unmounted and produces a console warning, a third treats a cancelled request as a genuine error. A custom hook pulls exactly this logic into a single, testable place, so a fix or improvement happens in one spot and automatically applies everywhere the hook is used.
2. The basic structure of useApiResource
The useApiResource hook takes a URL or a fetch function and returns an object with data, error, isLoading, and a refetch function. Internally it manages three state values through useState and a useEffect that re-runs whenever the URL changes. It matters to set the loading state before the actual fetch call and to explicitly reset the error state on every new attempt, otherwise a stale error message stays visible while fresh data is already loading.
Just as important is the order of state updates once the request completes: set the data first, then end the loading state, so a component never renders an intermediate state with isLoading false and empty data. These details sound small, but they are exactly the spots where hand-written fetch logic scattered across individual components typically drifts apart, since each place ends up implementing it slightly differently.
function useApiResource(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
let cancelled = false;
async function load() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
}
} catch (err) {
if (!cancelled) {
setError(err);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [url, reloadToken]);
const refetch = useCallback(() => setReloadToken((t) => t + 1), []);
return { data, error, isLoading, refetch };
}
3. Avoiding race conditions when the URL changes quickly
A mistake that hand-written fetch logic frequently overlooks involves rapidly successive requests: if a user quickly switches between multiple terms in a search field, several requests start in parallel, but they are not guaranteed to come back in the order they were sent. Without a safeguard, an older, slower response can overwrite an already more recent one, and the interface ends up showing data from the wrong, outdated request.
The cancelled flag in the useEffect cleanup inside useApiResource solves exactly this problem: as soon as the effect re-runs, for example because the URL changed, React marks the previous run as stale and calls its cleanup function, which sets cancelled to true. If the old request's response still arrives afterward, it gets silently discarded instead of overwriting the state. This is one of the main reasons a centralized hook is more robust than copies of the same logic scattered across multiple components.
4. Variants for POST requests and dependent queries
The basic pattern extends easily to mutations, for example a useApiMutation hook that takes an async function and only runs it once a returned execute function is called, instead of automatically on mount. That fits forms and buttons where a request should be explicitly triggered by a user action, while useApiResource is meant for data that is needed right when a component renders.
Dependent queries, where a second request must only start after a first request succeeds, for example loading user details only after receiving a user ID, can be solved by conditionally setting the second useApiResource call's URL to null while the prerequisite is missing. The hook then checks internally whether a valid URL is present and skips the fetch call otherwise, which makes the dependency chain explicit and readable without nested useEffect constructs.
function useApiMutation(mutationFn) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(
async (payload) => {
setIsLoading(true);
setError(null);
try {
return await mutationFn(payload);
} catch (err) {
setError(err);
throw err;
} finally {
setIsLoading(false);
}
},
[mutationFn]
);
return { execute, isLoading, error };
}
// Dependent query: only load once userId is available
function UserDetails({ userId }) {
const { data: user } = useApiResource(userId ? `/api/users/${userId}` : null);
return user ? <p>{user.name}</p> : null;
}
5. Where a hand-rolled hook hits its limits
A custom hook like useApiResource solves the three core problems of loading, error, and race conditions solidly, but it deliberately does not cover what a mature data-fetching library adds on top. That includes a shared cache across multiple components, so that two components loading the same URL do not each trigger their own separate request, automatic refetching when the browser tab regains focus, and a configurable stale time after which data refreshes in the background automatically.
Retry logic with exponential backoff for failed requests, optimistic updates for mutations, and deduplication of simultaneous identical requests are all features one could rebuild by hand, but their correct implementation carries a surprising number of edge cases. That is exactly the point where the effort of maintaining a hand-rolled solution starts to outweigh the benefit it originally had over an off-the-shelf library.
6. When TanStack Query is clearly the better choice
As soon as an application has multiple views displaying the same data from different components, for example a product list and a cart widget both querying the same product endpoint, a shared cache becomes the deciding factor. TanStack Query automatically deduplicates such requests, keeps data in sync across the entire application, and invalidates it precisely after mutations, without requiring manual orchestration.
For requirements like infinite scroll, pagination with prefetched next pages, automatic retry with backoff, or offline support, switching to TanStack Query is almost always the more economical decision compared to expanding a hand-rolled hook further. The custom useApiResource hook still remains valuable, particularly for smaller projects, prototypes, or single, isolated requests where the additional dependency and its learning curve do not justify the benefit.
import { useQuery } from "@tanstack/react-query";
function ProductList() {
const { data, error, isLoading } = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then((res) => res.json()),
staleTime: 60_000,
});
if (isLoading) return <p>Loading products...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
7. Testing custom hooks in isolation
A central advantage of a custom hook over scattered fetch logic is testability: useApiResource can be tested in isolation with tools like renderHook from React Testing Library, without ever rendering the components that will later use it. A mocked fetch is enough to walk through the loading, error, and success cases deliberately, while also verifying the cancelled safeguard behaves correctly under rapidly changing URLs.
That isolation pays off especially during refactors: if the API's response format changes later, or an additional header needs to be sent, a single adjustment in the hook, backed by its own tests, is enough, instead of having to manually re-verify every individual component that has ever loaded data from that spot.
8. Type safety with generic hooks
In TypeScript projects, it pays off to make useApiResource generic over the data type, for example useApiResource
Combined with a Zod schema or another runtime validation approach, it becomes possible to guarantee that the JSON actually received matches the expected type, instead of blindly trusting the server. That catches cases where a backend endpoint changes without the frontend type being updated in sync, turning what would otherwise be a runtime-only failure into a controlled error message right inside the hook.
function useApiResource<T>(url: string | null) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!url) {
setIsLoading(false);
return;
}
let cancelled = false;
(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url);
const json = (await response.json()) as T;
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err as Error);
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [url]);
return { data, error, isLoading };
}
9. Making the decision in practice
For an internal admin tool with a handful of isolated views, a prototype that needs quick validation, or a single component with a standalone request, a lean useApiResource hook is often the right choice: no extra dependency, full control over the behavior, and the three core problems of loading, error, and race conditions are already solved cleanly.
But as soon as multiple components share the same data, users regularly switch between tabs and expect fresh data, or mutations need to synchronously update existing views, the effort of rebuilding all of that by hand quickly outweighs the effort of introducing TanStack Query and learning its concepts like query keys and invalidation. The table below sums up that trade-off concretely.
| Criterion | Custom useApiResource hook | TanStack Query | Recommendation |
|---|---|---|---|
| Shared cache across components | Not included, must be built by hand | Automatic via query keys | Prefer TanStack Query for endpoints used in multiple places |
| Extra dependency | None, plain React code | One more library in the bundle | Prefer a custom hook for small projects |
| Retry with backoff, offline support | Must be rebuilt manually | Included and configurable out of the box | Prefer TanStack Query on unstable networks |
| Learning curve for the team | Low, plain React knowledge suffices | An extra concept: query keys, invalidation | Consider a custom hook first for small teams |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Custom Hooks for Data Fetching: The Essentials at a Glance
Core idea
Bundle loading, error, and data state plus race-condition protection in one place instead of rewriting them per component.
Safeguard
A cancelled flag in the useEffect cleanup prevents stale responses from overwriting more recent data.
DIY limit
A shared cache, automatic refetching, and retry logic are surprisingly hard to rebuild correctly by hand.
Switching to a library
Once multiple components share the same data, TanStack Query is almost always the more economical choice.