Building Custom Hooks for Reusable Data-Fetching Logic
AI generated
{ }
React · Custom Hooks · Data Fetching
Custom Hooks for Reusable Data-Fetching Logic
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.

14 min read Custom hooks Data fetching TanStack Query

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(url), so that data is already correctly typed at the call site instead of being treated as unknown or any. That catches field-name typos at compile time and makes IDE autocompletion immediately usable, without manual type assertions scattered at every usage site.

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.

11. FAQ: Custom Hooks for Data Fetching: The Essentials at a Glance

1What exactly does the useApiResource hook encapsulate?
It bundles the three states data, error, and isLoading together with a refetch function into one reusable hook, so components just consume these four values instead of writing their own combinations of useState and useEffect.
2Why is the cancelled flag inside useEffect so important?
It prevents the response of a stale request, for example after a quick URL change, from overwriting state with outdated data. Without that safeguard, race conditions can cause the interface to display the wrong data.
3Can I also use useApiResource for POST requests?
In principle yes, but a separate useApiMutation hook is more sensible, since it only triggers the request on an explicit user action via an execute function instead of automatically on every render of the component.
4How do I test a custom hook like useApiResource in isolation?
With renderHook from React Testing Library, the hook can run without a surrounding component. A mocked fetch makes it possible to deliberately walk through the loading, success, and error cases, as well as the behavior under rapidly changing URLs.
5At what point does switching to TanStack Query pay off?
As soon as multiple components display the same data from different endpoints, a shared cache is needed, or features like automatic refetching, retry with backoff, or pagination are required, the effort of a hand-rolled solution usually outweighs its benefit.
6Do I lose type safety in TypeScript with a custom hook?
No, useApiResource can easily be made generic over the expected data type, for example useApiResource(url), so that data arrives correctly typed at the call site, without any or unknown.
7What happens if two components use the same useApiResource hook with the same URL at the same time?
Without a shared cache, both components independently trigger their own request, so the server gets queried twice. That is exactly one of the main reasons to switch to TanStack Query and its query-key-based cache for such cases.
8Do I have to solve dependent queries with nested useEffect calls?
No, it is enough to conditionally set the dependent hook's URL to null while the prerequisite is missing, for example a still-unknown user ID. The hook then internally skips the fetch call, without requiring nested effects.
9Is a hand-rolled hook worse than a library?
Not fundamentally, it solves the core problems of loading, error, and race conditions solidly and often remains the simpler choice for small projects or isolated requests. Only once state needs to be shared across multiple components or requirements grow more complex does a library clearly pull ahead.
10Can I later migrate from useApiResource to TanStack Query without rewriting everything?
Yes, since both can share the same basic return shape of data, error, and isLoading, the migration can happen component by component by swapping the hook import and turning the fetch function into a queryFn.