useEffect explained, dependencies, cleanup, infinite loops
useEffect runs code after render. What the dependency array controls, why cleanup exists, and the four bugs that bite everyone in the same order.
useEffect runs a piece of code after React has rendered your component to the screen. You give it a function and a list of values, and React re-runs that function whenever one of those values changes. It exists for the things rendering cannot do: talking to a server, setting a timer, listening to the window.
The hook itself is four lines. The trouble is that it silently connects three separate ideas, and getting any one wrong produces a bug that does not look like it came from useEffect at all.
useEffect(() => {
// 1. what to do after render
return () => {
// 2. how to undo it
};
}, [deps]); // 3. when to do it again
This post covers what each part controls, then the four bugs, in the order they usually bite.
The dependency array, in one table
Everything about when an effect runs comes down to the second argument.
| You write | React runs the effect | Use it for |
|---|---|---|
useEffect(fn) |
After every render | Almost never |
useEffect(fn, []) |
Once, after the first render | Setup that never depends on changing values |
useEffect(fn, [a, b]) |
After the first render, then whenever a or b changes |
Nearly everything real |
The comparison React makes is Object.is on each item, which behaves like === for the values you care about. That single detail is the source of the infinite loop later on, so hold on to it.
What useEffect is actually for
An effect synchronises your component with something outside React. The React docs put it as connecting to an external system, and that phrase is a better filter than any rule of thumb:
- Fetching data from a server
- Subscribing to a WebSocket or an event emitter
setTimeoutandsetIntervalwindow.addEventListener- Reading or writing
localStorage - Manually touching a DOM node through a ref
What is not on that list is just as important. Transforming data for display, filtering a list, computing a total, or reacting to a click are all things that belong in the render body or an event handler. We will come back to that, because it is bug number four and the most expensive one.
Bug 1: the missing dependency
You leave something out of the array, and your effect keeps using an old value.
function Search({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then(setResults);
}, []); // ← query is missing
return <ResultList items={results} />;
}
Type a new query and nothing happens. The effect ran once, on the first render, with whatever query was then. It closed over that value and never asked for another.
That word, closed, is the actual explanation. An effect is a function, and a function written inside a component captures the variables around it. Change the props and React makes a new function with new values, but it only runs the new one if the dependency array says something changed. Leave query out and you are permanently running the first render's version. If that idea is fuzzy, Closures Explained is the underlying concept, and useEffect makes a lot more sense afterwards.
The fix is to list what you use:
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then(setResults);
}, [query]);
Turn on the react-hooks/exhaustive-deps ESLint rule and it will point at every one of these. When it complains, the answer is almost never to silence it. It is to add the dependency, and then to deal with what that reveals, which is usually bug two.
Bug 2: the object dependency that loops forever
You add the dependency the linter asked for and your app locks up.
function Profile({ userId }) {
const [user, setUser] = useState(null);
const options = { userId, include: "avatar" }; // new object every render
useEffect(() => {
fetchUser(options).then(setUser);
}, [options]); // ← never equal to last time
return <Card user={user} />;
}
Here is the loop. The effect runs, setUser triggers a re-render, the re-render creates a brand new options object, React compares it to the previous one, and two objects with identical contents are not Object.is equal. So the effect runs again. Forever.
Same story for arrays, and for functions defined in the component body. All three are new references on every render.
Three fixes, best first:
Depend on the primitives instead. Usually the object was never the real dependency.
useEffect(() => {
fetchUser({ userId, include: "avatar" }).then(setUser);
}, [userId]);
Move it outside the component if it never changes.
const OPTIONS = { include: "avatar" };
function Profile({ userId }) {
useEffect(() => {
fetchUser({ ...OPTIONS, userId }).then(setUser);
}, [userId]);
}
Memoise it if it genuinely has to be an object built from props.
const options = useMemo(() => ({ userId, include: "avatar" }), [userId]);
Reach for useMemo last. It is a real fix, but it adds a second dependency array to keep correct, and the primitive version is shorter and harder to get wrong. React's own guide, Removing Effect Dependencies, works through this at length.
Bug 3: no cleanup, so things pile up
The function you return from an effect is its cleanup. React calls it before running the effect again, and once more when the component unmounts.
Without it, subscriptions stack:
useEffect(() => {
window.addEventListener("resize", onResize);
}, [width]); // a listener added every time width changes, none removed
With it, they do not:
useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [width]);
Anything you start, stop. Listener, timer, subscription, connection.
Fetching has a subtler version of the same problem: the race. Type fast in a search box and request 1 can land after request 3, overwriting newer results with older ones. Cleanup fixes it by marking the old effect as no longer the one in charge:
useEffect(() => {
let active = true;
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
if (active) setResults(data);
});
return () => {
active = false;
};
}, [query]);
Each run gets its own active, another closure doing quiet work. When query changes, the previous run's cleanup sets its own active to false, so when its response finally arrives it declines to write.
AbortController does the same job and cancels the request too, which is better when you control the server. The active flag is the version that always works.
Bug 4: it should not have been an effect
This is the expensive one, because the code works. It just does twice the renders and creates a category of bug that cannot exist otherwise.
// Don't
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
total is not state. It is a fact about items. Storing it means React renders once with a stale total, runs the effect, sets state, and renders again. Two renders for a number you already had.
// Do
const total = items.reduce((sum, i) => sum + i.price, 0);
One line, no hook, no dependency array, and total cannot be out of sync with items because it is recalculated from them every render.
The same applies to effects that respond to user actions. Sending an analytics event when a form is submitted belongs in the submit handler, not in an effect watching a flag. React wrote a whole page about this, You Might Not Need an Effect, and it is the highest-value React page there is once you are past the basics.
The test: if the code should run because something changed on the screen, it might be an effect. If it should run because the user did something, it is an event handler.
When effects actually run
The order matters more than people expect, and knowing it explains a whole category of layout flicker.
For one render, React does this:
- Runs your component function and works out the new UI.
- Updates the DOM.
- Lets the browser paint. The user sees the new screen here.
- Runs your effects.
Step 3 is the important one. useEffect runs after the user has already seen the result. That is the right trade for anything asynchronous, since blocking paint on a network request would be terrible.
It is the wrong trade if the effect changes what things look like. Measure an element and reposition it in a useEffect and the user sees one frame in the wrong place first, which reads as a flicker.
For that specific case, and only that case, useLayoutEffect swaps steps 3 and 4: it runs after the DOM update and before paint, so nobody sees the intermediate state. It blocks painting, so use it for DOM measurement and nothing else.
Cleanup slots into the same sequence. Before an effect runs again, React runs the previous run's cleanup first. Always in that order, never two effects overlapping.
Why your effect runs twice in development
You add a console.log and see it twice. Nothing is broken.
In development, React's StrictMode deliberately mounts each component, unmounts it, and mounts it again. It is a test, and what it tests is exactly bug three: an effect that cleans up properly survives it invisibly, and an effect that does not shows you the damage on your own machine instead of in production.
Two log lines means your cleanup is missing or incomplete. Do not disable StrictMode to make the symptom go away. It only turns off the smoke alarm.
The checklist
Before you ship an effect:
- Should this be an effect at all? Derived value, or a response to a user action? Then no.
- Is every value the effect reads in the dependency array? Let the linter answer.
- Is any dependency an object, array, or function? Depend on primitives, or memoise.
- Does the effect start anything? Return a cleanup that stops it.
- Does it fetch? Guard against the response from a previous run.
- Does StrictMode's double mount produce duplicates? Then step 4 is not done.
Most useEffect pain is one of those six, and they stay in that order.
Build it, do not just read it
Effects are hard to learn from prose because the bugs are about timing, and timing is invisible on a page. React Essentials runs your components in a live preview, so you can add a dependency, watch the effect re-run, remove the cleanup, and see the listeners stack up.
Related reading: Closures Explained for the mechanism underneath the dependency array, Vibe Coding, and Exactly Where It Breaks on why generated effects are so often missing their cleanup, and the React hub for where hooks sit in the wider picture.
More from the blog
zip() in Python: loop over two lists at once
zip pairs items by position and stops at the shortest input, discarding the rest silently. strict=True is the fix.
Read moreSorting in Python: sort() vs sorted() vs key=
sort() changes the list and returns None. sorted() returns a new list. The key argument is what makes both useful.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.