HomebaseWorks
Lesson 4 of 4

Fetching data: loading, errors, cleanup and custom hooks

Load data in an effect with loading and error state, cancel stale requests, and move the logic into a reusable hook.

EWritten by the HomebaseWorks editorial team · reviewed 26 Oct 2026

Loading a list from an API looks simple until the URL changes while a request is still running, or the component unmounts before the response arrives. Screenings test exactly these edges. The pattern below handles them, and packaging it as a custom hook is the kind of answer that stands out.

Advertisement

The three states every fetch needs

Track loading, error and data separately. Check res.ok yourself, because fetch only rejects on network failure, not on a 404 or 500.

import { useEffect, useState } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error("Request failed with status " + res.status);
        return res.json();
      })
      .then((json) => {
        setData(json);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== "AbortError") {
          setError(err);
          setLoading(false);
        }
      });

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

  return { data, error, loading };
}

The cleanup function runs before the effect runs again and when the component unmounts. Aborting the old request means a slow response for the previous URL can never overwrite the data for the new one.

Using the hook

function UserList({ teamId }) {
  const { data, error, loading } = useFetch("/api/teams/" + teamId + "/users");

  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">{error.message}</p>;

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

When teamId changes, the URL changes, the effect re-runs, the previous request is aborted and the list reloads. Nothing else in the component needs to know about timing.

async/await inside an effect

The effect callback cannot itself be async, because React expects it to return a cleanup function, not a promise. Define an inner async function and call it:

useEffect(() => {
  let active = true;
  async function load() {
    const res = await fetch(url);
    const json = await res.json();
    if (active) setData(json);
  }
  load();
  return () => { active = false; };
}, [url]);

The active flag is the simpler cousin of AbortController: the request still completes, but its result is ignored.

Common mistakes and a practice habit

  • Leaving the dependency array empty while using url inside, so the hook never refetches.
  • Rendering data.map before data arrives, which crashes on null. The early returns above prevent that.
  • Being surprised that the effect runs twice in development. React Strict Mode does this on purpose to test your cleanup.
  • Putting the fetch in the render body instead of an effect, which fires a request on every render.

Practice habit: pick any public JSON endpoint, build useFetch from memory, then add a search box that changes the URL as you type and confirm in the network tab that old requests are cancelled.

Advertisement
Continue with React4 lessons · 1 interview set · 1 test · 1 certificate
Not tested

More in Web and app