Effects and Data Fetching · Lesson 13 of 31
Fetching Data in React
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–fetchonly 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>
);
}
[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>
);
}
[] 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>
);
}
[] 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:
| Option | What it gives you |
|---|---|
| TanStack Query (React Query) | caching, deduping, retries, refetch on focus, mutations, pagination |
| SWR | a lighter "stale-while-revalidate" cache |
| React Router loaders | data loaded per route before the page renders (covered in the routing chapter) |
| Next.js Server Components | fetch 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
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>; }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.