Skip to content
Topic 10 of 13
Theory

Custom hooks and data loading

Custom hooks

The hooks useState, useEffect, useRef, useContext are building blocks. You can assemble your own hooks from them. A custom hook is an ordinary function that:

  • has a name starting with use (useToggle, useWindowWidth, useFetch);
  • calls other hooks inside.

Why? To extract logic from a component and use it in several places. Compare:

// Before: the window size logic is right in the component
function Header() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width < 600 ? <MobileMenu /> : <DesktopMenu />;
}
// After: the logic is in a hook, the component reads at a glance
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}

function Header() {
  const width = useWindowWidth();
  return width < 600 ? <MobileMenu /> : <DesktopMenu />;
}

function Sidebar() {
  const width = useWindowWidth();   // the same logic in one more place
  return width < 900 ? null : <aside>…</aside>;
}

Important: hooks share logic, not state. Each call of useWindowWidth() creates its own state and its own subscription — as if the code were pasted right into the component. For different components to share the same data, you need context (or a common parent).

The rules of hooks apply to custom ones too: call them only at the top level of a component or another hook, not inside conditions and loops. The use name isn't just tradition: by it React and the linter know that the function must be checked against these rules.

Loading data: three states

Loading from a server is the most common effect. It always has three states, and you need to show each one:

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function load() {
      try {
        const response = await fetch("https://api.example.com/products");
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        setProducts(await response.json());
      } catch (e) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    }
    load();
  }, []);

  if (loading) return <p>Loading…</p>;
  if (error) return <p className="error">Failed to load: {error}</p>;
  return (
    <ul>
      {products.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

What matters here:

  • the effect function can't be made async: useEffect(async () => …) returns a promise, while React expects an effect to return either nothing or a cleanup function. That's why async function load() is declared inside and called right away;
  • we check response.ok ourselves: fetch doesn't consider a 404 or 500 response an error (the fetch topic of the JavaScript course);
  • finally turns off the loading indicator in any case.

Loading when parameters change

If the address depends on props or state, it's listed in the dependencies — and the effect re-runs on every change:

function UserPage({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    setUser(null);                    // drop the old one while the new one loads
    fetch(`https://api.example.com/users/${userId}`)
      .then((r) => r.json())
      .then(setUser);
  }, [userId]);

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

The response race

This code hides a bug found in many applications. The user quickly opened Ann's profile and then Timur's. Two requests went out. The responses arrive in any order: if the server answered about Ann later than about Timur, Ann will end up on Timur's page.

The solution is the effect's cleanup function. Before re-running an effect (and when the component is removed) React calls the cleanup of the old effect. In it we mark that the old response is no longer needed:

useEffect(() => {
  let ignore = false;

  async function load() {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    const data = await response.json();
    if (!ignore) setUser(data);       // the old effect is already "switched off" — don't touch state
  }
  load();

  return () => {
    ignore = true;
  };
}, [userId]);

Each run of the effect has its own ignore variable (a closure!). When userId changes, the cleanup sets ignore = true for the old run, and its response, whenever it arrives, is skipped.

Even better is to cancel the request itself so you don't waste traffic. fetch has AbortController for that:

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => {
      if (e.name !== "AbortError") setError(e.message);
    });
  return () => controller.abort();   // cancel the request when url changes or the page is left
}, [url]);

The useFetch hook

All of it together fits nicely into a custom hook — and components become really simple:

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

  useEffect(() => {
    let ignore = false;
    async function load() {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const json = await response.json();
        if (!ignore) setData(json);
      } catch (e) {
        if (!ignore) setError(e.message);
      } finally {
        if (!ignore) setLoading(false);
      }
    }
    load();
    return () => {
      ignore = true;
    };
  }, [url]);

  return { data, loading, error };
}

function Weather({ city }) {
  const { data, loading, error } = useFetch(`https://api.example.com/weather?city=${city}`);
  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error}</p>;
  return <p>{city}: {data.temp}°</p>;
}

What big projects use

A custom useFetch is good for understanding and for small applications. In big projects data loading is usually handed to the TanStack Query (React Query) or SWR library: they add a cache (reopening a page is instant), retries, and refreshing when you return to the tab. They're built on the same pattern: a hook that returns data, isLoading and error.

And frameworks like Next.js can load data on the server, before the page even reaches the browser.

Summary

  • A custom hook is a useSomething function that calls other hooks. It shares logic, not state.
  • Loading: the states loading, data, error; the effect function isn't made async — declare async function load() inside.
  • Request parameters go in the effect's dependencies.
  • The response race: let ignore = false + the cleanup () => { ignore = true }, or AbortController.
  • In big projects — TanStack Query or SWR.

The useToggle hook

Easy+10 XP

Write your own hook useToggle(initial). It keeps a boolean value (false if initial isn't passed) and returns an array [value, toggle], where toggle() flips the value.

function Menu() {
  const [open, toggle] = useToggle();
  return <button onClick={toggle}>{open ? "Close" : "Open"}</button>;
}

Connect the connector to solve tasks

Task code runs on your own computer — faster and more reliable than in the browser. This needs the free CodeWithMe connector app. You only connect it once.

Looking for the connector on this computer…

  1. 1
    Download the connector for your system (Windows, macOS or Linux).Download the connectorOn the download page pick the file with your system in its name, e.g. windows-amd64.
  2. 2Run it and choose a folder for your solutions. It will install Python and Node.js there by itself when needed.
  3. 3
    Enter below the 6-digit code shown in the connector window (“Code for CodeWithMe”). If the browser asks about local network access — allow it.

The connector is open source (on GitHub), works only on your computer and runs code only after you enter the pairing code.