Run effects after render, clean them up, and fetch data with clear loading, success and error states.
Why it matters
Rendering should be pure, but apps must talk to the outside world: load data from an API, start a timer, subscribe to a WebSocket, set the page title. useEffect is how a component synchronises with something outside React after it renders. Loading data with clear loading and error states is one of the most common tasks in front-end work.
The shape of an effect
React.useEffect(() => {
// runs after React has updated the screen
document.title = `${count} new messages`;
return () => {
// optional cleanup: runs before the effect runs again, and on unmount
};
}, [count]); // dependency array
The dependency array controls when the effect runs:
| Dependencies | Runs |
|---|---|
[count] | after the first render, then whenever count changes |
[] | once, after the first render (on mount) |
| none | after every render (rarely what you want) |
List every prop and state value the effect reads. The React ESLint plugin (react-hooks/exhaustive-deps) warns when one is missing.
Cleanup
Anything an effect starts, its cleanup should stop:
React.useEffect(() => {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, []);
Without the cleanup, every remount would add another interval. In development, React's <StrictMode> deliberately mounts, unmounts and remounts components once to expose missing cleanups, so you may see effects run twice in development. That is expected; fix the cleanup rather than fighting it.
Fetching data with loading and error states
A request can be pending, succeed or fail, and the UI should show each case:
function UserList() {
const [users, setUsers] = React.useState([]);
const [status, setStatus] = React.useState("loading"); // "loading" | "done" | "error"
React.useEffect(() => {
let ignore = false;
fetch("https://api.example.com/users")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
if (!ignore) {
setUsers(data);
setStatus("done");
}
})
.catch(() => {
if (!ignore) setStatus("error");
});
return () => {
ignore = true;
};
}, []);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p role="alert">Could not load users.</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Key points:
fetchdoes not reject on HTTP errors. A 404 or 500 still resolves; checkres.okand throw yourself.- The
ignoreflag prevents a race. If the component unmounts, or the effect re-runs because a dependency (such as a search term) changed, the cleanup marks the old request as stale so its late answer does not overwrite newer data.AbortControllercan cancel the request itself: pass{ signal: controller.signal }tofetchand callcontroller.abort()in the cleanup. - One status value is clearer than separate
isLoadingandhasErrorbooleans that can contradict each other. - The effect callback itself cannot be
async(it must return a cleanup function or nothing). Define an async function inside it and call it, or use.thenchains as above.
Fetching when something changes
React.useEffect(() => {
let ignore = false;
setStatus("loading");
fetch(`/api/products?q=${encodeURIComponent(query)}`)
.then((r) => r.json())
.then((data) => { if (!ignore) { setProducts(data); setStatus("done"); } })
.catch(() => { if (!ignore) setStatus("error"); });
return () => { ignore = true; };
}, [query]);
Each new query starts a request; the cleanup ignores the previous one.
Making effects testable
Passing the data-loading function in as a prop (or importing it from a module you can mock) lets tests supply fake data, a never-ending promise or a failure. The Do exercise works this way, because the exercise preview has no network.
You might not need an effect
Effects are for synchronising with external systems. You do not need one to:
- Compute values from props or state. Calculate them during render (
const visible = items.filter(...)). - Respond to a click. Put that logic in the event handler.
- Reset state when a prop changes. Give the component a
keyso React remounts it.
In larger apps, data fetching usually moves to a framework (Next.js data loading) or a library such as TanStack Query, which adds caching, retries and deduplication. They still follow the same pending/success/error model you are learning here.
Common mistakes
- Missing dependencies, so the effect uses stale values; or an object or function created during render in the dependency list, so the effect runs every render.
- Setting state that is also a dependency, creating an infinite loop.
- Forgetting cleanup for timers, subscriptions and listeners.
- Treating any
fetchresponse as success.
Recap
useEffect(fn, deps)runs after render to sync with the outside world; return a cleanup.- Model requests as loading, success and error, and render each.
- Use an
ignoreflag orAbortControllerto drop stale responses. - Do not use effects for derived data or for event logic.
Try it
Open the Do tab to load users in an effect and show loading, list and error states. The checks pass in their own fake loaders, including one that fails.