Almost every real app shows data that lives somewhere else: a list of products, a user's profile, search results. Getting it into a component is one of the first tasks you will meet, and it has more pitfalls than it appears to. This lesson builds the pattern step by step: the basic fetch, loading and error states, race conditions, and what to use in larger apps.
The basic pattern
Fetching is a side effect, so it goes in useEffect. You keep three things in state: the data, whether it is loading, and any error. Start with the shortest working version, which loads a user from a public test API:
import { useEffect, useState } from "react";
function App() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users/1")
.then(response => response.json())
.then(data => setUser(data));
}, []);
if (!user) return <p>Loading...</p>;
return <p>{user.name} ({user.email})</p>;
}
export default App;Leanne Graham (Sincere@april.biz)While the request is in flight, user is null and the component shows "Loading...". When the response arrives, setUser stores it and React renders the name. The empty dependency array runs the fetch once when the component appears. Two things are missing from this tiny version: an error path, and the fact that in development StrictMode starts the request twice (you will see two requests in the browser's network panel; only the last result is used, and that is harmless for a read-only request).
Loading, success and error states
Use one status value rather than several booleans (the state-structure lesson explained why). async/await reads more clearly than chained .then:
import { useEffect, useState } from "react";
function UserCard({ id }) {
const [status, setStatus] = useState("loading"); // loading | success | error
const [user, setUser] = useState(null);
useEffect(() => {
async function load() {
setStatus("loading");
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users/" + id);
if (!response.ok) throw new Error("Server answered " + response.status);
setUser(await response.json());
setStatus("success");
} catch (error) {
setStatus("error");
}
}
load();
}, [id]);
if (status === "loading") return <p>Loading user {id}...</p>;
if (status === "error") return <p role="alert">Could not load user {id}.</p>;
return <p>User {id}: {user.name}</p>;
}
function App() {
return (
<div>
<UserCard id={1} />
<UserCard id={9999} />
</div>
);
}
export default App;User 1: Leanne Graham
Could not load user 9999.User 1 loads; user 9999 does not exist, so the server answers 404 and the component shows the error. Three details matter:
- The effect function cannot be
async(it must return nothing or a cleanup). So define anasyncfunction inside and call it, as above. fetchdoes not reject on 404 or 500. It only rejects when the request cannot be made at all (offline, blocked, bad address). Always checkresponse.ok(true for statuses 200 to 299) and throw yourself, or the error page's text will be fed to.json().idis a dependency because the effect reads it. When the prop changes, the effect runs again and loads the new user.
Race conditions
Here is the trap in that last point. Suppose the user quickly switches from item 1 to item 2. Two requests are in flight. Network responses can come back in any order. If the response for item 1 is slow and the one for item 2 is fast, item 2's data arrives first and is shown, then item 1's data arrives later and replaces it. The page now says item 1 while the selection says item 2.
To reproduce it reliably, the next example uses a fake API in which item 1 takes 400 ms and item 2 takes 100 ms. The buttons are pressed in order: "Load 1", then "Load 2":
import { useEffect, useState } from "react";
function fakeApi(id) {
const delay = id === 1 ? 400 : 100; // item 1 is slow
return new Promise(resolve => setTimeout(() => resolve("Data for item " + id), delay));
}
function App() {
const [id, setId] = useState(0);
const [text, setText] = useState("Choose an item");
useEffect(() => {
if (id === 0) return;
fakeApi(id).then(result => setText(result)); // no protection against old responses
}, [id]);
return (
<div>
<button className="one" onClick={() => setId(1)}>Load 1</button>
<button className="two" onClick={() => setId(2)}>Load 2</button>
<p>Selected: {id} | Showing: {text}</p>
</div>
);
}
export default App;Load 1Load 2
Selected: 2 | Showing: Data for item 1The selected item is 2, yet the text shows "Data for item 1": the slow, stale response arrived last and won. This bug is real and common, and it is invisible when you test on a fast connection.
The fix: ignore outdated responses
Use the effect's cleanup to mark the request as outdated. React runs the cleanup before the effect runs again for a new id, so the earlier request can discard its own result:
import { useEffect, useState } from "react";
function fakeApi(id) {
const delay = id === 1 ? 400 : 100;
return new Promise(resolve => setTimeout(() => resolve("Data for item " + id), delay));
}
function App() {
const [id, setId] = useState(0);
const [text, setText] = useState("Choose an item");
useEffect(() => {
if (id === 0) return;
let ignore = false; // belongs to this effect run only
fakeApi(id).then(result => {
if (!ignore) setText(result);
});
return () => { ignore = true; }; // the next run (or unmount) makes this one stale
}, [id]);
return (
<div>
<button className="one" onClick={() => setId(1)}>Load 1</button>
<button className="two" onClick={() => setId(2)}>Load 2</button>
<p>Selected: {id} | Showing: {text}</p>
</div>
);
}
export default App;Load 1Load 2
Selected: 2 | Showing: Data for item 2Now the stale response is thrown away, and the page shows item 2. Each run of the effect has its own ignore variable (a closure), and changing id triggers the cleanup of the previous run, flipping its flag to true. This also prevents "set state on an unmounted component" updates when the component leaves while a request is pending.
Cancelling the request: AbortController
Ignoring the result is enough for correctness, but the request keeps running and using bandwidth. For real fetch calls you can also cancel the request with an AbortController:
import { useEffect, useState } from "react";
function App() {
const [todos, setTodos] = useState([]);
const [status, setStatus] = useState("loading");
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=3", { signal: controller.signal });
if (!response.ok) throw new Error("HTTP " + response.status);
setTodos(await response.json());
setStatus("success");
} catch (error) {
if (error.name === "AbortError") return; // cancelled on purpose: not a real error
setStatus("error");
}
}
load();
return () => controller.abort(); // cancel if we leave or re-run
}, []);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p role="alert">Failed to load.</p>;
return (
<ul>
{todos.map(item => (
<li key={item.id}>{item.completed ? "✔" : "○"} {item.title}</li>
))}
</ul>
);
}
export default App;○ delectus aut autem
○ quis ut nam facilis et officia qui
○ fugiat veniam minusAborting makes fetch reject with an AbortError. That is not a failure, so the catch ignores it. This is also why, in development, StrictMode's extra mount aborts the first request and starts a second one.
Refetching and fetching on events
Not all fetching is tied to the component appearing:
- To refetch on demand (a Refresh button), put the load logic in a function you can call from the handler.
- To search as the user types, make the query a dependency (and consider waiting a moment, called debouncing, before sending, so you do not send a request for every letter).
- To send data (POST, PUT, DELETE) because the user clicked, use the event handler, not an effect. Remember that effects are for synchronising, and handlers for reacting to actions.
async function handleSave() {
setSaving(true);
try {
const response = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!response.ok) throw new Error("HTTP " + response.status);
} catch (error) {
setError("Could not save.");
} finally {
setSaving(false);
}
}Why hand-written fetching does not scale
The effect-and-state pattern teaches the mechanics, but a real app has to handle much more: sharing results between components, caching so the same request is not repeated, refetching when the window regains focus, retries, pagination, optimistic updates and race conditions everywhere. Writing all of that by hand in every component invites bugs. Teams usually reach for one of:
- TanStack Query (React Query) or SWR: libraries that provide
useQuery-style hooks with caching, deduplication, retries and background refresh. - A framework with built-in data loading: Next.js, React Router in framework mode, or Remix, which fetch on the server before rendering.
- React 19's
usewith Suspense: a way to read a promise during render and let a parent show a loading fallback, shown in a later lesson. You still need to create and cache the promise outside the render.
For small components and learning, the pattern in this lesson is enough. In a bigger project, adopt a library early instead of rebuilding its features.
Security and practicality
- Never put secret API keys in React code; everything in the browser is visible to users. Call a server you control that holds the secret.
- Use environment variables with the Vite prefix for non-secret settings, such as
import.meta.env.VITE_API_URL. - The server must allow your site's origin with CORS headers, or the browser will block the response.
- Handle empty results and slow connections: show skeletons or messages, not a blank page.
Common mistakes
- Making the effect callback
asyncdirectly. - Forgetting
response.ok, so a 404 page is parsed as JSON and crashes. - Omitting the dependency, so the data never updates when the id changes.
- Letting an old response overwrite a newer one (no cleanup).
- Setting state after unmount without protection.
- Starting fetches during render instead of in an effect.
- Fetching in an effect for something that should be a handler (submitting a form).
- Showing no loading or error state at all.
Exercise
- Add a "Refresh" button to the task list that loads it again, showing "Loading..." while it does.
- Make
UserCardaccept anidchosen with a<select>, and verify that quickly changing the selection never shows the wrong user (add theignoreflag orAbortController). - Add an input that searches todos by title with the
qquery parameter, and wait 300 ms after typing stops before fetching. - Handle the offline case: what message does the user see? Try the browser's "Offline" network setting.
Key points
- Fetch inside useEffect, store the result in state, and render a loading, an error or a success view depending on a status.
- fetch only rejects on network failure; check response.ok yourself to catch 404 and 500 errors.
- When the request depends on a prop or state, ignore or abort the previous request in the cleanup so a slow old response cannot overwrite a newer one.
- In bigger apps use a data library such as TanStack Query or SWR, or a framework, which handle caching, retries and race conditions for you.