Back to blog
Oct 2026·2 min readAI-generated

Building a Custom Data-Fetching Hook in React

#react#javascript#frontend

Every React codebase eventually accumulates a dozen near-identical useEffect blocks that fetch data, track loading state, and handle errors. Extracting that pattern into a single reusable hook isn't just less typing — done right, it also fixes a couple of subtle bugs that copy-pasted useEffect fetches almost always have.

The naive version, and what's wrong with it

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

This looks reasonable but has a real bug: if userId changes quickly (fast navigation between profiles), multiple fetches are in flight at once, and there's no guarantee the last response to resolve corresponds to the current userId. You can end up rendering a stale user because an older, slower request finished after a newer, faster one.

Extracting the hook, with cancellation

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json();
      })
      .then((data) => {
        if (!cancelled) setState({ data, loading: false, error: null });
      })
      .catch((error) => {
        if (!cancelled) setState({ data: null, loading: false, error });
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return state;
}
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;
  return <h1>{user.name}</h1>;
}

The cancelled flag is the key fix: when url changes (or the component unmounts), the effect's cleanup function runs and flips cancelled to true for the previous effect run. If that older fetch resolves later, its .then() callback checks the flag and bails out instead of overwriting fresher state with stale data.

Using AbortController instead of a boolean flag

The cancelled flag stops React from acting on a stale response, but the network request itself keeps running to completion, wasting bandwidth. AbortController actually cancels the in-flight request:

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((error) => {
        if (error.name !== 'AbortError') {
          setState({ data: null, loading: false, error });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

The check for error.name !== 'AbortError' matters: aborting a fetch rejects its promise, and without that check, every cleanup-triggered abort would incorrectly set an error state on a component that's already moved on.

Adding a manual refetch

A fixed url dependency handles automatic refetching when props change, but sometimes you need a "Retry" button that refetches the same URL. That requires a way to bump the effect without changing url itself:

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });
  const [refetchIndex, setRefetchIndex] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((error) => {
        if (error.name !== 'AbortError') {
          setState({ data: null, loading: false, error });
        }
      });

    return () => controller.abort();
  }, [url, refetchIndex]);

  const refetch = () => setRefetchIndex((n) => n + 1);

  return { ...state, refetch };
}

refetchIndex doesn't affect the request itself — it exists purely to be a dependency the effect can react to, giving callers a way to force a rerun without changing url.

What this hook still doesn't do

This version is a solid building block, but it's deliberately not a full data-fetching solution. It doesn't cache responses across components requesting the same URL, doesn't deduplicate simultaneous requests for the same resource, and doesn't retry on transient failures. That's exactly the gap libraries like React Query and SWR fill — they're built on the same useEffect + cancellation fundamentals shown here, plus a shared cache and request-deduplication layer on top. Writing your own version is worth doing once, to understand what's actually happening; reaching for one of those libraries once your app has more than a couple of data-dependent components is usually the right call.

Takeaway

A data-fetching hook is really a state machine (idle → loading → success/error) wrapped around a useEffect that's careful about cleanup. The cancellation logic isn't optional polish — without it, fast prop changes silently produce race conditions where an old response overwrites a new one.

OlderCSS Container Queries: A Practical Guide