Data fetching is the most common use of useEffect in React applications. The pattern is always the same: set loading state to true, make the request, set the data on success or set error on failure, and set loading to false. Getting this pattern right — handling all three states and cleaning up on unmount — is the foundation of reliable data-fetching in React. This lesson builds the complete fetch pattern used throughout the blog application’s components.
Complete Data Fetch Pattern
import { useState, useEffect } from "react";
function PostFeed({ page = 1, tag = null }) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset state when dependencies change
setIsLoading(true);
setError(null);
const url = new URL("/api/posts", window.location.origin);
url.searchParams.set("page", page);
url.searchParams.set("page_size", 10);
if (tag) url.searchParams.set("tag", tag);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
setData(json);
setIsLoading(false);
})
.catch((err) => {
if (err.name === "AbortError") return; // ignore intentional cancels
setError(err);
setIsLoading(false);
});
}, [page, tag]); // re-fetch when page or tag changes
if (isLoading) return <PostListSkeleton />;
if (error) return <ErrorMessage message={error.message} />;
if (!data) return null;
return (
<div>
<PostList posts={data.items} />
<Pagination page={page} total={data.total} pageSize={10} />
</div>
);
}
Note: Always check
res.ok before calling res.json(). The fetch API only rejects (throws) on network failures — a 404, 401, or 500 response from the server is considered a “successful” fetch from the network perspective, so catch is not triggered. Without the if (!res.ok) throw new Error(...) check, a 401 Unauthorized response silently sets your data state to the error body (e.g., {"detail": "Not authenticated"}) rather than going to the error state.Tip: Extract the fetch logic into a reusable
useFetch hook or use a library like TanStack Query (React Query) for production applications. Raw useEffect data fetching has several rough edges — race conditions, no caching, no background refetching, no request deduplication — that TanStack Query handles automatically. Chapter 41 covers integrating TanStack Query with the FastAPI backend. For now, the raw pattern builds understanding of what the library handles for you.Warning: Setting state after a component has unmounted causes a React warning: “Can’t perform a React state update on an unmounted component.” This happens when the component unmounts while a fetch is in flight and the
.then() callback runs after unmount. The fix is the cleanup function with AbortController (covered in Lesson 3). This warning was downgraded in React 18 (it no longer shows in the console) but the underlying stale update is still a logical bug — always cancel in-flight requests on cleanup.Fetching a Single Resource
function PostDetailPage({ postId }) {
const [post, setPost] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
setError(null);
setPost(null); // clear previous post while loading
fetch(`/api/posts/${postId}`)
.then((res) => {
if (!res.ok) {
if (res.status === 404) throw new Error("Post not found");
throw new Error(`Error ${res.status}`);
}
return res.json();
})
.then((data) => { setPost(data); setIsLoading(false); })
.catch((err) => {
if (err.name !== "AbortError") { setError(err); setIsLoading(false); }
});
}, [postId]);
if (isLoading) return <PostDetailSkeleton />;
if (error) return <ErrorPage message={error.message} />;
if (!post) return null;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
Handling Specific HTTP Status Codes
async function fetchWithStatus(url, options = {}) {
const res = await fetch(url, options);
if (res.ok) return res.json();
let errorMessage;
switch (res.status) {
case 400: errorMessage = "Invalid request"; break;
case 401: errorMessage = "Please log in"; break;
case 403: errorMessage = "Access denied"; break;
case 404: errorMessage = "Not found"; break;
case 409: errorMessage = "Conflict — already exists"; break;
case 422: {
const body = await res.json();
errorMessage = body.detail?.[0]?.msg ?? "Validation error";
break;
}
case 500: errorMessage = "Server error — try again"; break;
default: errorMessage = `Error ${res.status}`;
}
throw new Error(errorMessage);
}
Common Mistakes
Mistake 1 — Not checking res.ok (treating 404/500 as success)
❌ Wrong — 401 response body treated as data:
fetch("/api/posts/1")
.then((res) => res.json()) // parses {"detail": "Not authenticated"} as data!
.then((data) => setPost(data))
✅ Correct:
fetch("/api/posts/1")
.then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); })
.then((data) => setPost(data)) // ✓ only called on 2xx responses
Mistake 2 — Not resetting state between dependency changes
❌ Wrong — old post visible while new one loads:
useEffect(() => {
fetch(`/api/posts/${postId}`)
.then(r => r.json())
.then(setPost);
}, [postId]);
// While loading, the previous post's data is still shown!
✅ Correct — reset state at the top of the effect:
useEffect(() => {
setPost(null); setIsLoading(true); // ✓ clear before fetch
fetch(`/api/posts/${postId}`)...
}, [postId]);
Quick Reference
| Task | Code |
|---|---|
| Fetch on mount | useEffect(() => { fetch(...).then(setData) }, []) |
| Fetch on change | useEffect(() => { ... }, [param]) |
| Check response | if (!res.ok) throw new Error(res.status) |
| Handle 404 | if (res.status === 404) throw new Error("Not found") |
| Reset on change | setData(null); setIsLoading(true); at top of effect |
| Ignore abort | if (err.name === "AbortError") return; |