React 33 ⚛️ Fetching API Data with useEffect
Fetching data is the most common side effect in a React application. A component mounts, it needs data from a server, it shows a loading state, the data arrives, it shows the data. The pattern is simple to describe and full of traps: the effect that fetches can run twice, the response can arrive after the component unmounts, the dependencies can cause an infinite loop, and the loading and error states can be out of sync. This chapter covers data fetching with useEffect in full: the basic pattern, the loading and error states, the abort mechanism, the dependency rules, and the limitations that lead to dedicated data-fetching libraries.
Key point: Fetching in useEffect requires three states: the data, the loading flag, and the error. The effect fetches when the dependencies change, sets the loading state to true before the request, and sets the data or the error when the request completes. The cleanup aborts the request so that a response from a stale request does not set state on an unmounted component or overwrite a newer response. The pattern works, but it is manual, and the manual parts are where the bugs are.
Why fetching in useEffect matters
The mounting problem. A component that needs data from a server has to request it at some point. The request is a side effect, so it belongs in an effect, not in the render. The render should be pure. The effect runs after the render, and the request is made.
The state problem. A fetch is asynchronous. The component renders before the data arrives. It renders again when the data arrives. The component needs to represent the intermediate state: loading, loaded, or error. The states are managed with useState and updated by the effect.
The race problem. If the dependencies change while a request is in flight, a second request is made. The two requests can resolve in any order. The older response can arrive after the newer one and overwrite it with stale data. The cleanup aborts the older request, so the race cannot happen.
The unmount problem. A request that is in flight when the component unmounts resolves after the component is gone. The response handler calls setData on an unmounted component. In React 18, this is no longer a warning, but it is still a waste. The cleanup aborts the request on unmount.
The manual problem. The fetch pattern in useEffect requires the developer to manage the loading state, the error state, the abort, and the dependencies. Each is a place for a bug. The dedicated libraries—React Query, SWR, TanStack Query—exist because the manual pattern is repetitive and error-prone. But the manual pattern is the foundation, and understanding it is understanding what the libraries do.
a. The basic fetch pattern with loading and error states
The pattern uses three states: the data, the loading flag, and the error.
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(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{user.name}</p>;
}
The effect sets loading to true and clears the error before the request. The fetch returns a promise. The first .then checks the response status and parses the JSON. The second .then sets the data and clears the loading flag. The .catch sets the error and clears the loading flag.
The res.ok check is important. The fetch promise resolves for HTTP errors like 404 and 500. It only rejects for network errors. The res.ok property is true for status codes in the 200–299 range. A 404 response produces res.ok === false, and the code throws an error that the .catch handles.
The three render branches handle the three states. The loading branch is shown while the request is in flight. The error branch is shown when the request fails. The data branch is shown when the request succeeds.
An alternative is to use a single state object.
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
useEffect(() => {
setState({ data: null, loading: true, error: null });
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setState({ data, loading: false, error: null }))
.catch((error) => setState({ data: null, loading: false, error }));
}, [userId]);
The single object avoids the possibility of inconsistent states, where loading is false but data and error are both null. The three separate states can produce that combination if the updates are not coordinated.
The single object is the safer pattern. The three separate states are more common because they are simpler to write. Either works if the updates are coordinated.
b. The abort and the race condition
A fetch that is not aborted can resolve after the dependencies have changed or the component has unmounted. The cleanup aborts the request.
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
if (err.name === 'AbortError') return;
setError(err);
setLoading(false);
});
return () => controller.abort();
}, [userId]);
The AbortController creates a signal. The fetch is given the signal. The cleanup calls controller.abort(), which cancels the request. The .catch checks for the AbortError and returns early, because the abort is expected and should not set the error state.
When userId changes from 1 to 2:
- The cleanup from the
userId = 1effect runs, aborting the request for user 1. - The new effect runs, creating a new controller and fetching user 2.
The aborted request for user 1 rejects with an AbortError, which the .catch ignores. The response for user 1 never reaches setUser.
The abort on unmount works the same way. The cleanup runs when the component unmounts, and the request is cancelled. The response never sets state on the unmounted component.
Without the abort, the race is real.
// BAD: no abort
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
If userId changes from 1 to 2 quickly, both requests are in flight. If the request for user 1 resolves after the request for user 2, the data for user 1 overwrites the data for user 2. The component shows the wrong user. The abort prevents this.
A simpler alternative to AbortController is a boolean flag.
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
The flag is set to true in the cleanup. The .then checks the flag before setting state. The flag does not cancel the request; it only ignores the response. The AbortController cancels the request, which saves bandwidth. The flag is simpler and works when the fetch library does not support aborting.
The AbortController is the preferred pattern in modern code. It cancels the request, and the AbortError is a clear signal that the request was intentionally cancelled.
c. The dependency rules and the stale closure
The effect depends on the values it reads. The userId is a prop, so it belongs in the dependencies. The fetch URL is built from userId, so the effect re-runs when userId changes.
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
The effect reads userId and the setUser function. The setUser is stable and does not need to be in the dependencies. The userId is reactive and does.
An effect that reads a value without listing it is a stale closure.
// BAD: stale closure
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, []); // userId not in dependencies
The effect runs once on mount and fetches the user for the initial userId. When userId changes, the effect does not re-run, and the component shows the wrong user. The dependency is the fix.
An effect that depends on an object or a function re-runs when the object or function changes identity.
// BAD: options is a new object every render
function Component({ userId }) {
const options = { headers: { Authorization: token } };
useEffect(() => {
fetch(`/api/users/${userId}`, options)
.then((res) => res.json())
.then(setUser);
}, [userId, options]); // runs on every render
}
The options object is a new object on every render. The Object.is comparison is false, and the effect runs on every render. The fix is to depend on the primitive values.
useEffect(() => {
const options = { headers: { Authorization: token } };
fetch(`/api/users/${userId}`, options)
.then((res) => res.json())
.then(setUser);
}, [userId, token]);
The effect reads userId and token. Both are primitives. The effect re-runs when either changes, not on every render.
An alternative is to memoize the object.
const options = useMemo(
() => ({ headers: { Authorization: token } }),
[token]
);
useEffect(() => {
fetch(`/api/users/${userId}`, options)
.then((res) => res.json())
.then(setUser);
}, [userId, options]);
The useMemo returns the same object until token changes. The effect depends on the memoized object, which is stable between renders.
The effect should not update a value in its dependencies.
// BAD: infinite loop
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, [data]); // data is in dependencies and set by the effect
The effect sets data, which is in the dependencies, which causes the effect to re-run, which sets data again. The loop is infinite. The fix is to remove data from the dependencies. The effect does not read data; it only sets it.
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []); // runs once
The effect runs once and fetches the data. The setData does not need data in the dependencies because the effect does not read it.
Complete Example Session
// ============================================
// PART 1: BASIC FETCH
// ============================================
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) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{user.name}</p>;
}
// ============================================
// PART 2: SINGLE STATE OBJECT
// ============================================
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
useEffect(() => {
setState({ data: null, loading: true, error: null });
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setState({ data, loading: false, error: null }))
.catch((error) => setState({ data: null, loading: false, error }));
}, [userId]);
// ============================================
// PART 3: ABORT CONTROLLER
// ============================================
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
if (err.name === 'AbortError') return;
setError(err);
setLoading(false);
});
return () => controller.abort();
}, [userId]);
// ============================================
// PART 4: CANCELLED FLAG
// ============================================
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
// ============================================
// PART 5: RESPONSE STATUS CHECK
// ============================================
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch(setError);
// ============================================
// PART 6: ASYNC/AWAIT FORM
// ============================================
useEffect(() => {
const controller = new AbortController();
const load = async () => {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') setError(err);
} finally {
setLoading(false);
}
};
load();
return () => controller.abort();
}, [userId]);
// ============================================
// PART 7: OBJECT DEPENDENCY PROBLEM
// ============================================
// BAD: new object every render
const options = { headers: { Authorization: token } };
useEffect(() => {
fetch(url, options).then(setData);
}, [url, options]); // runs on every render
// ============================================
// PART 8: FIX WITH PRIMITIVES
// ============================================
useEffect(() => {
const options = { headers: { Authorization: token } };
fetch(url, options).then(setData);
}, [url, token]); // runs only when url or token changes
// ============================================
// PART 9: FIX WITH useMemo
// ============================================
const options = useMemo(
() => ({ headers: { Authorization: token } }),
[token]
);
useEffect(() => {
fetch(url, options).then(setData);
}, [url, options]);
// ============================================
// PART 10: AVOIDING THE INFINITE LOOP
// ============================================
// BAD: data is set by the effect and in the dependencies
useEffect(() => {
fetch('/api/data').then((res) => res.json()).then(setData);
}, [data]); // infinite loop
// GOOD: data is not read by the effect
useEffect(() => {
fetch('/api/data').then((res) => res.json()).then(setData);
}, []); // runs once
The ten parts covered the basic fetch, the single state object, the AbortController, the cancelled flag, the response status check, the async/await form, the object dependency problem, the primitive fix, the useMemo fix, and the infinite loop.
Quick Reference
Basic Fetch Pattern
| Step | Code |
|---|---|
| Data state | const [data, setData] = useState(null) |
| Loading state | const [loading, setLoading] = useState(true) |
| Error state | const [error, setError] = useState(null) |
| Fetch | fetch(url).then(res => res.json()).then(setData) |
| Abort | return () => controller.abort() |
Response Handling
| Check | Purpose |
|---|---|
res.ok | Status 200–299 |
res.status | Numeric status |
res.json() | Parse JSON |
res.text() | Parse text |
res.headers.get() | Read header |
Abort vs Flag
| Aspect | AbortController | Cancelled Flag |
|---|---|---|
| Cancels request | Yes | No |
| Ignores response | Yes | Yes |
| Saves bandwidth | Yes | No |
| Error handling | AbortError | Check flag |
| Supported | Modern fetch | Any promise |
Dependency Rules
| Value | In Dependencies |
|---|---|
userId prop | Yes |
token prop | Yes |
setData | No (stable) |
| URL string | Yes |
| Options object | No (new each render) |
| Options primitive | Yes |
useMemo options | Yes |
Common Bugs
| Bug | Cause | Fix |
|---|---|---|
| Infinite loop | Data in dependencies | Remove |
| Race condition | No abort | AbortController |
| Stale data | Missing dependency | Add to array |
| Re-fetch every render | Object dependency | Primitives or useMemo |
| Set state after unmount | No cleanup | Abort or flag |
Best Practices
✅ Do This:
// Use three states for data, loading, and error
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); // ✅
// Check res.ok before parsing
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ✅
// Abort the request in the cleanup
const controller = new AbortController();
return () => controller.abort(); // ✅
// Ignore AbortError in the catch
if (err.name === 'AbortError') return; // ✅
// Use primitives in the dependencies
}, [userId, token]); // ✅
// Use useMemo for object dependencies
const options = useMemo(() => ({ ... }), [token]); // ✅
// Use a single state object for consistency
setState({ data, loading: false, error: null }); // ✅
❌ Don’t Do This:
// Don't fetch without handling errors
fetch(url).then((res) => res.json()).then(setData); // ❌
// Don't forget to check res.ok
fetch(url).then((res) => res.json()).then(setData); // ⚠️
// Don't fetch without aborting
fetch(url).then(setData); // ❌ race
// Don't put the data state in the dependencies
useEffect(() => { setData(...); }, [data]); // ❌ loop
// Don't use a new object in the dependencies
useEffect(() => {}, [{ id }]); // ❌ re-runs
// Don't set state after unmount
fetch(url).then(setData); // ❌
// Don't put the fetch function in the dependencies
useEffect(() => { fetchData(); }, [fetchData]); // ⚠️ if unstable
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Infinite loop | Data in dependencies | Remove data |
| Race condition | No abort | AbortController |
| Stale data | Missing dependency | Add dependency |
| Runs on every render | Object dependency | Primitives |
| Set state after unmount | No cleanup | Abort or flag |
| Loading never clears | Error before setting | finally block |
| Error ignored | No .catch | Handle errors |
Real-World Examples
1. Basic Fetch
useEffect(() => {
fetch('/api/data').then((r) => r.json()).then(setData);
}, []);
2. Fetch on Prop Change
useEffect(() => {
fetch(`/api/users/${userId}`).then((r) => r.json()).then(setUser);
}, [userId]);
3. With Loading and Error
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
4. Abort Controller
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(setData);
return () => controller.abort();
}, [url]);
5. Async/Await
useEffect(() => {
const load = async () => {
const res = await fetch(url);
setData(await res.json());
};
load();
}, [url]);
6. Search with Debounce
useEffect(() => {
const id = setTimeout(() => {
fetch(`/api/search?q=${query}`).then(setResults);
}, 300);
return () => clearTimeout(id);
}, [query]);
7. POST Request
useEffect(() => {
fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
}).then((r) => r.json()).then(setResult);
}, [item]);
8. With Authentication
useEffect(() => {
fetch(url, {
headers: { Authorization: `Bearer ${token}` },
}).then(setData);
}, [url, token]);
9. Pagination
useEffect(() => {
fetch(`/api/items?page=${page}`).then(setItems);
}, [page]);
10. Refetch on Demand
const [refresh, setRefresh] = useState(0);
useEffect(() => {
fetch(url).then(setData);
}, [url, refresh]);
// Refetch: setRefresh((r) => r + 1)
Visual
Fetch Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ MOUNT │
│ Render (loading: true) → Effect → fetch │
│ │
│ REQUEST IN FLIGHT │
│ Component shows loading state │
│ │
│ RESPONSE ARRIVES │
│ setData + setLoading(false) → Re-render with data │
│ │
│ OR ERROR │
│ setError + setLoading(false) → Re-render with error │
│ │
│ UNMOUNT │
│ Cleanup → abort │
│ │
└─────────────────────────────────────────────────────────────┘
Race Condition
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT ABORT │
│ │
│ userId = 1: fetch starts │
│ userId = 2: fetch starts │
│ │
│ Request 1 resolves slowly: setUser(user1) │
│ Request 2 resolves fast: setUser(user2) │
│ │
│ Order of resolution: 1 then 2 → correct │
│ Order of resolution: 2 then 1 → WRONG (user1 overwrites) │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH ABORT │
│ │
│ userId = 1: fetch starts │
│ userId = 2: cleanup aborts request 1, fetch starts │
│ │
│ Request 1 aborted: no response │
│ Request 2 resolves: setUser(user2) │
│ │
│ Always correct. │
│ │
└─────────────────────────────────────────────────────────────┘
Three States
┌─────────────────────────────────────────────────────────────┐
│ STATE 1: LOADING │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ loading: true │ │
│ │ data: null │ │
│ │ error: null │ │
│ │ Renders: "Loading…" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ STATE 2: SUCCESS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ loading: false │ │
│ │ data: { ... } │ │
│ │ error: null │ │
│ │ Renders: the data │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ STATE 3: ERROR │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ loading: false │ │
│ │ data: null │ │
│ │ error: Error │ │
│ │ Renders: "Error: …" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Dependency Rules
┌─────────────────────────────────────────────────────────────┐
│ useEffect(() => { │
│ fetch(`/api/users/${userId}`, { headers: { Auth: token }})│
│ .then(setUser); │
│ }, [userId, token]); │
│ │
│ Reads: userId, token │
│ Sets: user │
│ │
│ Dependencies: [userId, token] │
│ ├── userId is read → include │
│ ├── token is read → include │
│ └── setUser is stable → exclude │
│ │
│ The effect re-runs when userId or token changes. │
│ It does not re-run when user changes (it set it). │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Data state | useState(null) |
| Loading state | useState(true) |
| Error state | useState(null) |
| Fetch | fetch(url).then(res => res.json()) |
| Status check | if (!res.ok) throw |
| Abort | AbortController + controller.abort() |
| Ignore abort | if (err.name === 'AbortError') return |
| Cleanup | return () => controller.abort() |
| Dependencies | Props and state the effect reads |
| Object dependency | useMemo or primitives |
| Infinite loop | Data in dependencies |
Key takeaways:
- Fetching in
useEffectrequires three states: data, loading, and error. The effect sets the loading state before the request and the data or error state when the request completes. The render branches on the three states. fetchdoes not reject on HTTP errors. The promise resolves for404and500. Theres.okcheck throws an error for status codes outside the 200–299 range, which the.catchhandles.- The cleanup aborts the request. The
AbortControllercancels the request when the dependencies change or the component unmounts. The.catchignores theAbortError. Without the abort, a stale response can overwrite a newer one. - The dependencies include every value the effect reads. The
userIdandtokenare read, so they are in the dependencies. ThesetUserfunction is stable, so it is not. A missing dependency causes a stale closure; an object dependency causes re-runs on every render. - The effect should not read the data it sets. An effect that sets
dataand readsdatahasdatain its dependencies and causes an infinite loop. The effect fetches and sets; it does not read. - The single state object avoids inconsistent states. The three separate states can produce
loading: falsewithdata: nullanderror: null. The single object updates all three together. - The manual pattern is the foundation for the libraries. React Query, SWR, and TanStack Query exist because the manual pattern is repetitive. They handle caching, deduplication, retries, and background refetching. The manual pattern is what they replace, and understanding it is understanding what they do.
Remember: Fetching in useEffect is the pattern that every React developer writes and every React codebase contains. It is simple to write and full of traps. The three states, the res.ok check, the abort, and the dependencies are the parts that must be right. The abort is the part that is most often forgotten, and the race condition it prevents is the bug that appears only under load. Write the cleanup. Check the status. Include the dependencies. And when the manual pattern becomes repetitive, reach for the library that does it for you.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!