What you'll learn
- Fetch data with loading and error states
- Control when an effect runs with its dependency array
- Clean up timers, subscriptions and stale requests in the effect's return function
- Recognise when an effect is not needed
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 arrayThe dependency arrayThe list passed to useEffect that decides when it re-runs: after mount only for [], or whenever a listed value changes. 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 cleanupThe function an effect returns; React calls it before the effect runs again and when the component unmounts. 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 raceA bug where an older request finishes after a newer one and overwrites its result; an ignore flag set in cleanup prevents it.. 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.
Key terms (5)
- useEffect
- A hook that runs code after React renders, to synchronise a component with something outside React.
- dependency array
- The list passed to useEffect that decides when it re-runs: after mount only for [], or whenever a listed value changes.
- cleanup
- The function an effect returns; React calls it before the effect runs again and when the component unmounts.
- race
- A bug where an older request finishes after a newer one and overwrites its result; an ignore flag set in cleanup prevents it.
- AbortController
- A browser API whose signal can be passed to fetch so the request can be cancelled with abort().