Data Fetching with useEffect

Reviewed & published by Brayan K

Data fetching means loading information from a server (an API) and showing it in your UI. Because a network request is a side effect, it belongs in useEffect. The reliable recipe: track loading, error, and data, run an async loader inside the effect, and clean up so a late response can't update an unmounted component.

Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

1️⃣ Three States: Loading, Error, Data

Every fetch goes through three situations. Give each its own state so your render always has exactly one thing to show:

function User({ id }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  // ... effect below ...
  if (loading) return <p>Loading...</p>;
  if (error)   return <p>Error: {error}</p>;
  return <h1>{data.name}</h1>;
}
// A fetch is always in ONE of three states. Model them as plain data.
function viewFor({ loading, error, data }) {
  if (loading) return "⏳ Loading...";
  if (error)   return "❌ " + error;
  return "✅ " + data;
}

console.log(viewFor({ loading: true,  error: null, data: null }));   // ⏳ Loading...
console.log(viewFor({ loading: false, error: "Network failed", data: null }));  // ❌ Network failed
console.log(viewFor({ loading: false, error: null, data: "Hello!" }));          // ✅ Hello!

// Your component renders ONE of these three at a time — never a blank screen.

Your turn — write the same three-way decision yourself:

// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Pick what to render based on the three states.

function render({ loading, error, data }) {
  // 1) If still loading, return the loading text.
  if (___) return "Loading...";
  // 2) If there's an error, return "Error: " + error.
  if (___) return "Error: " + error;
  // 3) Otherwise show the data.
  return "Data: " + data;
}

console.log(render({ loading: true,  error: null, data: null }));
console.log(render({ loading: false, error: "404", data: null }));
console.log(render({ loading: false, error: null, data: "ok" }));

// ✅ Expected output:
//    Loading...
//    Error: 404
//    Data: ok

2️⃣ An Async Loader Inside the Effect

You can't make the effect callback itself async (it would return a promise where React expects a cleanup). Instead, define an async function inside the effect and call it. Put the id in the deps so it re-fetches when the id changes.

// Simulate the fetch flow with promises (no real network here).
// In React this runs inside useEffect; we just await it directly.

function fakeFetch(url) {
  return Promise.resolve({ json: () => ({ name: "Ada", url }) });
}

async function load() {
  console.log("state: loading = true");
  const res = await fakeFetch("/api/user/1");   // await the request
  const data = await res.json();                // parse the body
  console.log("state: loading = false");
  console.log("got:", data.name);               // Ada
}

load();
// state: loading = true
// state: loading = false
// got: Ada

3️⃣ Cleanup — Ignore Stale Responses

If the component unmounts (or the id changes) before the request resolves, calling setState on the old result is a bug. Guard with an ignore flag flipped by the cleanup function:

// RACE GUARD: an "ignore" flag stops a stale response from updating state.
let screen = "loading";
function setScreen(v) { screen = v; }

function startEffect() {
  let ignore = false;                 // reset each effect run
  // pretend the response arrives later:
  const finish = () => {
    if (ignore) { console.log("ignored stale response"); return; }
    setScreen("data: Ada");
  };
  // cleanup: if we unmount first, flip the flag
  return { finish, cleanup: () => { ignore = true; } };
}

const a = startEffect();
a.cleanup();   // component unmounted BEFORE response
a.finish();    // response arrives -> ignored, no setState
console.log("final screen:", screen);   // loading  (never overwritten)

🧩 Reorder Challenge

These lines make a correct async loader. Put them in the right order:

A.   const res = await fetch(url);
B.   async function load() {
C.   const data = await res.json();
D.   setData(data);
E.   }
F.   load();

Common Errors (and the fix)

📋 Quick Reference

StepCode
Inner async loaderasync function load() { ... } load();
Make the requestconst res = await fetch(url);
Check statusif (!res.ok) throw new Error(...)
Re-fetch on change}, [id]);
Cleanup guardreturn () => { ignore = true; };

🧠 Quick Recall

1. Why can't the useEffect callback be async?

An async function returns a promise, but the effect must return nothing or a cleanup function. Define an inner async function and call it.

2. You fetch user id. What goes in the dependency array so it re-fetches per user?

[id] — the effect re-runs whenever the id changes.

3. What does the ignore flag prevent?

A stale response from a previous render setting state after the component unmounted or the id changed (a race condition).

Mini-Challenge: A Tiny load()

Write the async loader from the outline, await the fake fetch, and log the result. Run it and check your output.

// 🎯 MINI-CHALLENGE: a tiny load() flow
//
// 1. Write an async function load() that:
//      - logs "loading"
//      - awaits fakeFetch() (given below)
//      - logs "done: " + the resolved value
// 2. Call load().
//
// ✅ Expected output:
//    loading
//    done: 42

function fakeFetch() {
  return Promise.resolve(42);
}

// your code here

🎉 Lesson Complete

Practice quiz

Why does a network request belong inside useEffect?

  • Because fetch only works inside effects
  • Because useEffect makes the request faster
  • Because a request is a side effect and shouldn't run during render
  • Because render functions can be async

Answer: Because a request is a side effect and shouldn't run during render. Rendering must be pure. A network request is a side effect, so it belongs in useEffect rather than directly in the render body.

Which three pieces of state model a fetch well?

  • loading, error, data
  • start, middle, end
  • request, response, retry
  • open, closed, pending

Answer: loading, error, data. Tracking loading, error, and data lets you render exactly one of a spinner, an error message, or the result — never a half-rendered screen.

Why can't you make the useEffect callback itself async?

  • Async functions can't call fetch
  • React forbids await anywhere in components
  • It would run the effect twice
  • An async function returns a promise, but the effect must return nothing or a cleanup function

Answer: An async function returns a promise, but the effect must return nothing or a cleanup function. An async callback returns a promise, but useEffect expects undefined or a cleanup function. Define an async function inside the effect and call it.

What is the correct pattern for awaiting inside an effect?

  • Make the effect callback async
  • Define an inner async function (e.g. load()) and call it
  • Use await directly at the top of the effect
  • Return a promise from the effect

Answer: Define an inner async function (e.g. load()) and call it. Define an async function such as load() inside the effect, then invoke it: async function load() { ... } load();.

What does an 'ignore' flag (or AbortController) in cleanup prevent?

  • A stale response setting state after unmount or after the input changed
  • A second render of the component
  • The fetch from ever starting
  • Errors from being thrown

Answer: A stale response setting state after unmount or after the input changed. If the component unmounts or the id changes before the request resolves, the guard stops the stale response from calling setState — avoiding a race condition.

To re-fetch whenever a user id changes, what goes in the dependency array?

  • [] (empty array)
  • No dependency array at all
  • [id]
  • [data, loading, error]

Answer: [id]. Putting [id] in the deps makes the effect re-run whenever id changes, re-fetching the right user.

Where is the cleanup flag flipped to true?

  • Inside the async load function
  • In the function returned by the effect (the cleanup function)
  • In the render body
  • In a separate useState call

Answer: In the function returned by the effect (the cleanup function). The effect returns () => { ignore = true; }. React runs that cleanup before re-running the effect or on unmount, marking the previous request stale.

What commonly causes an infinite re-fetch loop?

  • Using an AbortController
  • Awaiting the response
  • Checking res.ok before parsing
  • Putting an object/array recreated each render in the deps, or omitting deps

Answer: Putting an object/array recreated each render in the deps, or omitting deps. A fresh object/array in the deps changes identity every render, re-running the effect endlessly. Depend on a stable primitive like id.

After await fetch(url), why check if (!res.ok) before using the body?

  • res.ok is required to call res.json()
  • fetch only rejects on HTTP 500 errors, not 404, so you must check the status yourself
  • It speeds up parsing
  • It is purely stylistic and optional

Answer: fetch only rejects on HTTP 500 errors, not 404, so you must check the status yourself. fetch resolves even for 404/500 responses; it only rejects on network failure. Check res.ok (or res.status) and throw to handle HTTP errors.

What do production apps typically use instead of hand-rolled useEffect fetching?

  • Nothing — useEffect is always best
  • Class component lifecycle methods only
  • A data library like React Query or SWR for caching, retries, and dedup
  • useLayoutEffect for fetching

Answer: A data library like React Query or SWR for caching, retries, and dedup. The useEffect pattern is a great foundation, but real apps usually reach for React Query or SWR, which add caching, retries, and request deduplication.

Continue this course

Frequently asked questions

Why do I track three pieces of state — data, loading, and error?

A fetch can be in one of three situations: still in flight (loading), failed (error), or done (data). Tracking all three lets you show a spinner, an error message, or the result — and never a half-rendered screen.

Why can't I make the useEffect callback async directly?

An async function returns a promise, but useEffect expects its callback to return either nothing or a cleanup function. Define an async function INSIDE the effect and call it, e.g. async function load() { ... }; load();.

What is the cleanup flag / AbortController for?

If the component unmounts or the request changes before the fetch resolves, you must not call setState on the old result. A boolean 'ignore' flag (or an AbortController) lets the cleanup function cancel the stale update and avoid a warning or a race condition.

Should I really fetch with useEffect in production apps?

It works and is great for learning, but real apps usually use a data library like React Query or SWR for caching, retries, and dedup. The useEffect pattern here is the foundation those libraries build on.