Fetching data: loading, errors, cleanup and custom hooks
Load data in an effect with loading and error state, cancel stale requests, and move the logic into a reusable hook.
Loading a list from an API looks simple until the URL changes while a request is still running, or the component unmounts before the response arrives. Screenings test exactly these edges. The pattern below handles them, and packaging it as a custom hook is the kind of answer that stands out.
The three states every fetch needs
Track loading, error and data separately. Check res.ok yourself, because fetch only rejects on network failure, not on a 404 or 500.
import { useEffect, useState } from "react";
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("Request failed with status " + res.status);
return res.json();
})
.then((json) => {
setData(json);
setLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
setError(err);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, error, loading };
}The cleanup function runs before the effect runs again and when the component unmounts. Aborting the old request means a slow response for the previous URL can never overwrite the data for the new one.
Using the hook
function UserList({ teamId }) {
const { data, error, loading } = useFetch("/api/teams/" + teamId + "/users");
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">{error.message}</p>;
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}When teamId changes, the URL changes, the effect re-runs, the previous request is aborted and the list reloads. Nothing else in the component needs to know about timing.
async/await inside an effect
The effect callback cannot itself be async, because React expects it to return a cleanup function, not a promise. Define an inner async function and call it:
useEffect(() => {
let active = true;
async function load() {
const res = await fetch(url);
const json = await res.json();
if (active) setData(json);
}
load();
return () => { active = false; };
}, [url]);The active flag is the simpler cousin of AbortController: the request still completes, but its result is ignored.
Common mistakes and a practice habit
- Leaving the dependency array empty while using
urlinside, so the hook never refetches. - Rendering
data.mapbefore data arrives, which crashes onnull. The early returns above prevent that. - Being surprised that the effect runs twice in development. React Strict Mode does this on purpose to test your cleanup.
- Putting the fetch in the render body instead of an effect, which fires a request on every render.
Practice habit: pick any public JSON endpoint, build useFetch from memory, then add a search box that changes the URL as you type and confirm in the network tab that old requests are cancelled.
More in Web and app