Fetching Data — fetch() and the Loading/Error/Data Pattern

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;

🧠 Test Yourself

Your API returns a 401 response with body {"detail": "Not authenticated"}. You call fetch(url).then(r => r.json()).then(setPost). What is stored in the post state?