import { useEffect, useState } from "react";
function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/users")
.then((response) => response.json())
.then((data) => setUsers(data))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading...</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}The empty dependency array [] ensures the fetch runs exactly once when the component mounts, not on every re-render (which would trigger endless repeated requests).
function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/users")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
})
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (users.length === 0) return <p>No users found.</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}Just as in Chapter 9, handling all three states explicitly (loading, error, empty) before the success case gives users clear feedback instead of a blank or broken-looking screen.
useEffect(() => {
async function loadUsers() {
const response = await fetch("/api/users");
const data = await response.json();
setUsers(data);
setLoading(false);
}
loadUsers();
}, []);The useEffect callback itself can't be marked async directly (React expects either nothing or a cleanup function returned, not a Promise), so the standard workaround is defining an async function inside the effect and calling it immediately.
Writing useEffect(async () => { ... }, []) directly causes a subtle bug: an async function always returns a Promise, and React expects useEffect to return either nothing or a cleanup function โ receiving a Promise instead breaks React's cleanup mechanism. Always define and call a separate async function inside the effect instead, as shown above.
Fetch a list of posts from a placeholder API, showing a loading message while waiting and handling any fetch errors gracefully.
import { useState, useEffect } from "react";
function Posts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/posts?_limit=5")
.then(res => res.json())
.then(setPosts)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading posts...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Q Should I use fetch() or a library like Axios?
Both work fine for learning and small projects โ fetch() is built into the browser with zero setup, while Axios offers a slightly friendlier API and some extra features. Many production apps eventually reach for dedicated data-fetching libraries like React Query, covered briefly in Chapter 25.
Q What's a race condition in data fetching?
It happens when a component re-fetches (e.g., because a search query changed) before the previous request finishes, and the old, slower request's response arrives after the new one, overwriting it with stale data. Cleanup functions and cancellation tokens (like AbortController) are used to guard against this in production apps.