AniUI Academy

Fetching Data in an Effect

The hand-rolled pattern for loading data on mount — loading/error/data state, the race-condition guard, and why real apps usually reach for a dedicated data-fetching library instead.

9 min read

Fetching data is one of the most common reasons to reach for useEffect — loading a network resource is a textbook example of "something outside React's rendering" that needs to run after a component mounts (or after a prop it depends on changes).

The pattern, built up

Start with the three things the UI needs to distinguish: still loading, failed, or succeeded with data.

import { useState, useEffect } from "react";
 
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    setLoading(true);
    setError(null);
 
    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json();
      })
      .then((data) => setUser(data))
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);
 
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <p>{user.name}</p>;
}

Each state is reset at the start of every fetch (setLoading(true), setError(null)) because switching to a new userId should show a loading state again, not silently keep showing the previous user while a new request is in flight.

The race condition this is missing

There's a real bug hiding here: if userId changes quickly — the user clicks through several profiles fast — nothing stops an older, slower request from resolving after a newer one and overwriting fresh state with stale data. Fetches don't resolve in a guaranteed order.

The fix, from the cleanup lesson, is to guard against exactly this:

useEffect(() => {
  let ignore = false;
  setLoading(true);
  setError(null);
 
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setUser(data);
    })
    .catch((err) => {
      if (!ignore) setError(err.message);
    })
    .finally(() => {
      if (!ignore) setLoading(false);
    });
 
  return () => {
    ignore = true;
  };
}, [userId]);

When userId changes again before a request finishes, cleanup sets ignore to true for that request's closure before the new effect starts — so even if the old response arrives later, its .then callbacks check ignore and do nothing. (AbortController, shown in the cleanup lesson, is the more thorough version of the same idea — it actually cancels the network request instead of just ignoring its result.)

Why real apps usually don't hand-write this

This pattern is worth understanding deeply, because it's exactly what's happening under the hood of any tool that fetches data — but writing it out by hand in every component that needs data gets repetitive and easy to get subtly wrong (forgetting the guard, forgetting to reset error, and so on). In practice, most production codebases reach for a dedicated data-fetching library — React Query and SWR are the two most common — that handles caching, deduplication of identical in-flight requests, automatic retries, and race conditions once, consistently, instead of once per component. This course teaches the effect-based pattern because it's what those libraries are built on, and because understanding it is what makes a library's behavior legible rather than magical.

Try it yourself

A simulated fetch (no real network call, so this runs anywhere) that demonstrates the loading → data flow, with the race-condition guard in place:

Try it yourself
Loading playground...

What to remember

  • The standard hand-rolled pattern tracks data, loading, and error as separate states because they represent genuinely different things the UI renders differently.
  • Fetches can resolve out of order; a guard (an ignore flag, or an AbortController) prevents a stale response from overwriting a newer one.
  • This pattern is the honest mechanics behind data-fetching libraries — most real applications reach for one (React Query, SWR) rather than reimplementing caching and race-condition handling per component.

Check yourself

3 questions · pass 3/3 to unlock useRef for the DOM and Mutable Values

up to 50
  1. 1.In the classic hand-rolled data-fetching effect, why are there usually three separate pieces of state (data, loading, error) instead of just one?

  2. 2.Why does fetching in an effect need a race-condition guard when the dependency (like a search query) can change quickly?

  3. 3.Why do many production React codebases prefer a library like React Query or SWR over a hand-written fetching effect?

3 left to answer