React useEffect: Side Effects and Fetching Data
Web DevelopmentLesson 6 of 65 min read
What you'll learn
- Explain what an effect is and when
useEffectruns - Use the dependency array and cleanup functions correctly
- Fetch data with loading and error states and cancel stale requests
- Recognise infinite effect loops and decide between an effect and an event handler
On this page
Rendering must be pure: given the same props and state, a component returns the same JSX. But real apps also need to do things outside React: call an API, start a timer, change the document title. useEffect is the escape hatch for those side effects.
What useEffect does
useEffect(setup, dependencies) runs your setup function after React has rendered and updated the screen. The dependency array tells React when to run it again. There are three shapes, and mixing them up causes most effect bugs.
| Dependency array | When the effect runs | Typical use |
|---|---|---|
| Omitted | After every render | Rare; usually a sign of a mistake |
[] (empty) | Once after the first render (twice in dev StrictMode) | Subscribe once, load initial data |
[userId, page] | After first render and whenever those values change | Refetch when an input changes |
A simple example is syncing the browser tab title with state. The effect reads the latest count and runs again only when count changes.
import { useEffect, useState } from "react";
export default function TitleCounter() {
const [unread, setUnread] = useState(0);
useEffect(() => {
document.title = unread > 0 ? "(" + unread + ") New messages" : "Inbox";
}, [unread]);
return <button onClick={() => setUnread(unread + 1)}>Simulate message</button>;
}Output
Tab title initially: Inbox After clicking twice: (2) New messages
The linter rule react-hooks/exhaustive-deps checks that every value from the component your effect uses is listed in the array. Trust it. Silencing it is the usual cause of stale-value bugs.
Cleanup: undoing what the effect did
If your effect starts something that keeps running, such as a timer, a subscription or an event listener, return a cleanup function. React calls it before the effect runs again and when the component unmounts. In development, StrictMode deliberately mounts, unmounts and re-mounts once so a missing cleanup shows up immediately as a doubled timer or listener.
import { useEffect, useState } from "react";
export default function Clock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds((s) => s + 1); // functional update avoids a stale closure
}, 1000);
return () => clearInterval(id); // cleanup
}, []);
return <p>Seconds on page: {seconds}</p>;
}Output
Seconds on page: 0 (one second later) Seconds on page: 1 (two seconds later) Seconds on page: 2
Fetching data with loading and error states
Fetching on mount is the classic use of an effect. A realistic component tracks three things: the data, whether it is loading, and any error. It should also cancel the request in cleanup, so a slow response for an old input cannot overwrite a newer one (a race condition).
import { useEffect, useState } from "react";
type User = { id: number; name: string; email: string };
export default function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users", {
signal: controller.signal,
});
if (!res.ok) {
throw new Error("Request failed with status " + res.status);
}
const data: User[] = await res.json();
setUsers(data);
setLoading(false);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return; // the effect was cleaned up; ignore
}
setError(err instanceof Error ? err.message : "Something went wrong");
setLoading(false);
}
}
load();
return () => controller.abort();
}, []);
if (loading) return <p>Loading users...</p>;
if (error) return <p role="alert">Could not load users: {error}</p>;
if (users.length === 0) return <p>No users found.</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>
{u.name} ({u.email})
</li>
))}
</ul>
);
}Output
While waiting: Loading users... On success: a list such as "Leanne Graham (...)" for each user On network error: Could not load users: Failed to fetch
The effect callback itself cannot be async, because an async function returns a promise and React expects either nothing or a cleanup function. That is why the example declares an inner async function load() and calls it.
To refetch when an input changes, put it in the dependency array and reset the loading state at the start of the effect. For example, useEffect(() => { setLoading(true); ... }, [userId]). Each change runs the previous cleanup first, which aborts the older request.
Effect or event handler?
Ask: why does this code run? If it runs because the user did something (clicked Save, submitted a form), it belongs in an event handler. If it runs because the component is on screen and must stay synchronised with something external (a subscription, a chart widget, the page title, data for the current userId), it belongs in an effect.
Also ask whether you need an effect at all. If you can compute a value from props or state, do it during render: const total = items.reduce(...). Copying props into state in an effect, or resetting state in an effect when a prop changes, is a frequent source of extra renders and flicker. Use a key to reset, or derive the value.
Common mistakes
- An effect that sets state it depends on.
useEffect(() => { setCount(count + 1); }, [count]);updatescount, which re-runs the effect, forever. Symptom: the tab freezes or you see "Maximum update depth exceeded". - No dependency array plus a state update.
useEffect(() => { fetchData().then(setData); });runs after every render, and setting the data causes another render. Symptom: endless network requests. - Object or array dependencies recreated every render.
const options = { page };is a new object each render, so[options]always looks changed. Depend on the primitives (page) instead, or memoise the object. - Stale closures. An effect with
[]that readscountalways sees the first value. Symptom: a timer logs 0 forever. Add the dependency, or use the functional update form. - Missing cleanup. Symptom: duplicate listeners, timers that stack, and slow memory leaks in long-lived pages.
- Making the effect callback async.
useEffect(async () => ...)returns a promise, which React cannot use as cleanup. Call an inner async function instead. - Older code with class components. You may see
componentDidMountandcomponentWillUnmountin legacy projects. They map roughly to an effect with[]and its cleanup, but new code should use hooks.
How this shows up in interviews and real projects
Interviewers ask what the dependency array does, when cleanup runs, how to avoid race conditions in fetching, and why an effect can loop forever. If you can walk through the three dependency shapes and the abort pattern, you are ahead of most candidates. In real apps, effects are where the hardest-to-reproduce bugs live, so keeping them few and small is a professional habit. To go further, build a small app that lists data from a public API and deploy it, then look at jobs asking for React and check the skills that commonly appear alongside it.
Try it yourself
Build a profile viewer that fetches https://jsonplaceholder.typicode.com/users/1 through /users/10 based on a number input or a Next/Previous button. Show loading and error states, and make sure switching quickly between users never shows the wrong profile.
Show a hint
Keep userId in state and put it in the dependency array. Create an AbortController inside the effect, pass its signal to fetch, and return () => controller.abort(). Call setLoading(true) and clear the error at the start of the effect.
Key takeaways
useEffectruns after render to synchronise with the outside world; the dependency array controls when it re-runs.- Return a cleanup function for timers, listeners, subscriptions and in-flight requests.
- Track data, loading and error states when fetching, and abort or ignore stale requests.
- Infinite loops come from effects that update state they depend on, or from dependencies recreated on every render.
- User actions belong in event handlers; effects are for keeping the screen in sync with external systems.
Test yourself
A trick from this lesson's ride: Ask why it runs: user did something means handler, screen must stay in sync means effect. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How does the useEffect dependency array work?
React compares each value in the array with its value from the previous render. If any changed, it runs the cleanup from the last effect and then runs the effect again. An empty array runs once after mount, and omitting the array runs after every render.
Why is my useEffect running twice in React?
In development, StrictMode intentionally mounts, unmounts and re-mounts components once to help you find effects with missing cleanup. It does not happen in production. If the double run causes a visible problem, such as duplicate subscriptions, add a proper cleanup function.
How do I fix an infinite loop in useEffect?
Check whether the effect sets state that is also in its dependency array, or whether it has no dependency array at all. Remove the dependency by using the functional update form, move the logic into an event handler, or depend on stable primitive values instead of objects recreated each render.