Requesting data from an API
Getting data from a server is a classic side effect: it happens outside
React, it's asynchronous and shouldn't happen during render. That's why the usual
pattern is to fire the request inside a useEffect and store the result in state.
Since the network takes time and may fail, it's modeled with three states:
loading→ the request is in progress (no data yet).error→ the request failed (we show a message).data→ the data arrived (we render it).
function User({ id }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true; // to ignore stale responses
setLoading(true);
setError(null);
fetch("/api/users/" + id)
.then((res) => {
if (!res.ok) throw new Error("Response " + res.status);
return res.json();
})
.then((json) => {
if (active) { setData(json); setLoading(false); }
})
.catch((err) => {
if (active) { setError(err.message); setLoading(false); }
});
return () => { active = false; }; // cleanup: avoids "race conditions"
}, [id]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error}</p>;
return <h2>{data.name}</h2>;
}
Key points of this pattern:
- While the data arrives a loading state is shown (
Loading…). The user never sees a broken screen while waiting for the network. - The request goes in
useEffectwith[id]as a dependency: if theidchanges, the correct user is loaded again. - The cleanup (
active = false) discards responses that arrive late, when the component already requested something else or unmounted (avoids race conditions).
In real apps this logic is usually delegated to libraries like React Query or SWR, which handle caching, retries and states for you. But they all rely on this same
loading / error / datapattern.