LearnFrenzy
Sign in Take a free test

Effects and Data Fetching · Lesson 13 of 31

Fetching Data in React

8 min read Updated Nov 8, 2026 Saurabh Samir

Almost every app loads data from a server. In a client-side React app the basic tool is fetch inside useEffect. It looks simple, but a production-quality version has to handle loading, errors, changing inputs and responses that arrive out of order. This lesson builds that version step by step.

In this lesson

  • A fetch with loading and error states
  • Async functions inside effects
  • Race conditions, shown live – and two fixes
  • When to reach for TanStack Query or a framework

The basic pattern

Here's the real code you'd write against an API:

import { useEffect, useState } from "react";

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [status, setStatus] = useState("loading");   // "loading" | "success" | "error"
  const [error, setError] = useState(null);

  useEffect(() => {
    async function load() {                         // effects can't be async, so define one inside
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users");
        if (!res.ok) throw new Error(`HTTP ${res.status}`);   // fetch doesn't throw on 404/500
        setUsers(await res.json());
        setStatus("success");
      } catch (err) {
        setError(err.message);
        setStatus("error");
      }
    }
    load();
  }, []);

  if (status === "loading") return <p>Loading users…</p>;
  if (status === "error") return <p role="alert">Couldn't load users: {error}</p>;
  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Two classic mistakes

  • useEffect(async () => …) – an async function returns a promise, but React expects the effect to return nothing or a cleanup function. Define the async function inside and call it.
  • Forgetting res.ok – fetch only rejects on network failure. A 404 or 500 response still resolves, so check the status yourself.

Running it

So the examples below run anywhere, they use a small fake API with a delay instead of a real server. The React code is exactly what you'd write with fetch.

import { useEffect, useState } from "react";

// fake API: resolves after 300 ms, fails for an unknown category
const db = { frontend: ["React.js", "Next.js", "TypeScript"], backend: ["Node.js", "SQL"] };
function fetchCourses(category) {
  return new Promise((resolve, reject) =>
    setTimeout(() => (db[category] ? resolve(db[category]) : reject(new Error("404 Not Found"))), 300)
  );
}

function CourseList({ category }) {
  const [courses, setCourses] = useState([]);
  const [status, setStatus] = useState("loading");
  const [error, setError] = useState(null);

  useEffect(() => {
    setStatus("loading");
    fetchCourses(category)
      .then((data) => { setCourses(data); setStatus("success"); })
      .catch((err) => { setError(err.message); setStatus("error"); });
  }, [category]);                                   // refetch when the category changes

  if (status === "loading") return <p>Loading {category}…</p>;
  if (status === "error") return <p role="alert">Error: {error}</p>;
  return <ul>{courses.map((c) => <li key={c}>{c}</li>)}</ul>;
}

export default function App() {
  const [category, setCategory] = useState("frontend");
  return (
    <div>
      <button onClick={() => setCategory("frontend")}>Frontend</button>
      <button onClick={() => setCategory("backend")}>Backend</button>
      <button onClick={() => setCategory("devops")}>DevOps</button>
      <CourseList category={category} />
    </div>
  );
}

Result

[Frontend] [Backend] [DevOps]
Loading frontend…

› wait 350ms
[Frontend] [Backend] [DevOps]
• React.js
• Next.js
• TypeScript

› click “Backend”
[Frontend] [Backend] [DevOps]
Loading backend…

› wait 350ms
[Frontend] [Backend] [DevOps]
• Node.js
• SQL

› click “DevOps”
[Frontend] [Backend] [DevOps]
Loading devops…

› wait 350ms
[Frontend] [Backend] [DevOps]
Error: 404 Not Found

The race condition

Responses don't always arrive in the order requests were sent. In this search, slow queries take longer – type "react" quickly and the response for an earlier keystroke can land last:

import { useEffect, useState } from "react";

const all = ["react", "react router", "redux", "remix", "rust"];
function searchApi(q) {
  const delay = 500 - q.length * 80;               // shorter queries are slower here
  return new Promise((r) => setTimeout(() => r(all.filter((x) => x.startsWith(q))), delay));
}

export default function App() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return;
    searchApi(query).then((data) => setResults(data));   // ✗ no protection against stale responses
  }, [query]);

  return (
    <div>
      <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>Results for "{query}": {results.join(", ")}</p>
    </div>
  );
}

Result

[]
Results for "":

› type “reac” into Search
[reac]
Results for "reac":

› wait 600ms
[reac]
Results for "reac": react, react router, redux, remix, rust

The box says "reac", but the results include "redux", "remix" and "rust". The slow response for the first keystroke, "r", arrived after the one for "reac" and overwrote it.

Fix 1: ignore stale responses in the cleanup

import { useEffect, useState } from "react";

const all = ["react", "react router", "redux", "remix", "rust"];
function searchApi(q) {
  const delay = 500 - q.length * 80;
  return new Promise((r) => setTimeout(() => r(all.filter((x) => x.startsWith(q))), delay));
}

export default function App() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return;
    let ignore = false;                             // belongs to this run of the effect
    searchApi(query).then((data) => {
      if (!ignore) setResults(data);                // only the latest run may update state
    });
    return () => { ignore = true; };                // a newer query (or unmount) cancels this run
  }, [query]);

  return (
    <div>
      <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>Results for "{query}": {results.join(", ")}</p>
    </div>
  );
}

Result

[]
Results for "":

› type “reac” into Search
[reac]
Results for "reac":

› wait 600ms
[reac]
Results for "reac": react, react router

Fix 2: cancel the request with AbortController

With real fetch, you can actually cancel the HTTP request, which also saves bandwidth:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== "AbortError") setError(err.message);   // aborting isn't a real error
    });
  return () => controller.abort();                            // cancel when query changes or on unmount
}, [query]);

Beyond useEffect

Hand-written fetching has no caching, no retry, no deduplication and no background refresh. In production apps, teams usually use one of these:

OptionWhat it gives you
TanStack Query (React Query)caching, deduping, retries, refetch on focus, mutations, pagination
SWRa lighter "stale-while-revalidate" cache
React Router loadersdata loaded per route before the page renders (covered in the routing chapter)
Next.js Server Componentsfetch on the server with async components; no client effect at all
// The same list with TanStack Query
import { useQuery } from "@tanstack/react-query";

function CourseList({ category }) {
  const { data = [], isPending, error } = useQuery({
    queryKey: ["courses", category],                          // cached per category
    queryFn: () => fetch(`/api/courses?c=${category}`).then((r) => r.json()),
  });
  if (isPending) return <p>Loading…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.title}</li>)}</ul>;
}

Know the basics first

Libraries hide the details, but interviews and debugging still need them. Understand the useEffect version – status, errors, cleanup and races – then use a library in real projects.

Practice

Try it yourself

  1. Add a "Retry" button to the error state that runs the request again.

    Show solution
    import { useEffect, useState } from "react";
    
    let calls = 0;
    function fetchStats() {                              // fails the first time, then succeeds
      calls++;
      return new Promise((resolve, reject) =>
        setTimeout(() => (calls === 1 ? reject(new Error("Server busy")) : resolve({ learners: 12840 })), 200)
      );
    }
    
    export default function App() {
      const [state, setState] = useState({ status: "loading" });
      const [attempt, setAttempt] = useState(0);
    
      useEffect(() => {
        let ignore = false;
        setState({ status: "loading" });
        fetchStats()
          .then((data) => !ignore && setState({ status: "success", data }))
          .catch((err) => !ignore && setState({ status: "error", error: err.message }));
        return () => { ignore = true; };
      }, [attempt]);                                     // changing attempt re-runs the effect
    
      if (state.status === "loading") return <p>Loading…</p>;
      if (state.status === "error")
        return <p>{state.error} <button onClick={() => setAttempt((a) => a + 1)}>Retry</button></p>;
      return <p>{state.data.learners.toLocaleString("en-IN")} learners this month</p>;
    }

    Result

    Loading…
    
    › wait 250ms
    Server busy [Retry]
    
    › click “Retry”
    Loading…
    
    › wait 250ms
    12,840 learners this month

Quick check

Why can't the effect function itself be async?

It would return a promise; React expects nothing or a cleanup function.

Does fetch reject on a 500 response?

No – check res.ok and throw yourself.

How do you stop an old response overwriting a newer one?

Set an ignore flag in the cleanup, or abort the request with AbortController.

Summary

  • Fetch in useEffect with an inner async function and explicit loading, error and success states.
  • Check res.ok; put every input the request uses in the dependency array.
  • Guard against races with an ignore flag or AbortController; use TanStack Query or framework loaders in production.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular