React useEffect Common Mistakes and How to Avoid Them

React useEffect Common Mistakes and How to Avoid Them

If there’s a React hook that almost all the software developers use in an incorrect way at least once, it’s useEffect.

On the surface, this hook is straightforward—it gives you a way to perform side effects in a function component. But in reality, it’s also the chief enabler of many infinite loops, performance hiccups, and mysterious warnings.

Knowing how to use useEffect correctly can be the difference between a slick, predictable app and one that is unstable.

In this post, we’ll explore some common useEffect mistakes, explain why they occur, and demonstrate how to fix them with simple, real-world examples. We’ll also share some React useEffect best practices for writing cleaner, more efficient components.

What is useEffect in React?

The React useEffect hook allows your component to perform side effects—operations that interact with something outside the scope of React’s rendering cycle, such as making network requests, setting up timers (or intervals), creating subscriptions, or manipulating the DOM directly.

Let’s have a look at the example below:

import { useEffect, useState } from "react";

function Users() {
const [data, setData] = useState([]);

useEffect(() => {
  fetch("/api/users")
    .then((res) => res.json())
    .then(setData);
}, []);

return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

In this case, the effect will run after your component renders once and accesses the data, updating your state. The empty dependency array (“[ ]”) tells React to run it only during mount. It is important to understand this timing; it will help you avoid most of the useEffect pitfalls by knowing when it runs and why it re-runs.

How does React useEffect work

Mistake #1: Missing and Incorrectly Specified Dependencies

If you’ve ever seen the “React Hook useEffect has a missing dependency” warning, then you’ve already experienced one of the most frequent useEffect errors.

Each effect only matters for some values. When one of these things changes, React re-invokes the effect. Forgetting, or mis-declaring, those dependencies results in bugs that are difficult to track down.

// ❌ Wrong: missing dependency
useEffect(() => {
fetch(`/api/user/${userId}`);
}, []);

// ✅ Correct
useEffect(() => {
fetch(`/api/user/${userId}`);
}, [userId]);

​In the first case, you change the userId, but the effect never re-runs, so you are fetching stale data. You put it in the dependency list so that React can be aware when the variable changes.

📌 Pro Tip: The ESLint rule react-hooks/exhaustive-deps provides you with a way to manage the React useEffect dependency array best practices, and it doesn’t bother you about stuff that might be okay.

React useEffect dependency options

Mistake #2: Causing Infinite Loops

Another one of the useEffect common mistakes: an infinite loop. This typically happens when an effect calls a state variable that is also in its dependency array.

// ❌ Causes infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);

Here is the flow: React runs the effect, the state is updated; then, React re-renders the component and runs the effect again, so the state is updated again, and the flow repeats forever. You can fix that by extracting the state update away, or use a condition:

// ✅ Fixed
useEffect(() => {
if (count < 10) {
  const id = setTimeout(() => setCount((c) => c + 1), 1000);
  return () => clearTimeout(id);
}
}, [count]);

With the fix, the component will stop updating at 10 and properly clear the timeout. 

And that raises the next point of this article: understanding how you’re controlling updates with the dependency array is key to understanding how not to make an infinite loop in useEffect.

Mistake #3: Using Async Functions in useEffect Directly

You might be tempted to write this using useEffect(async => {… }), but React doesn’t like that either because it expects the function you pass to useEffect to either return nothing or a cleanup function, not a Promise. 

If you make it async, React does not know how to deal with the returned Promise, and you’re just ending up with paradoxical errors when calling useEffect.

// ❌ Wrong
useEffect(async () => {
const res = await fetch("/api");
const data = await res.json();
setData(data);
}, []);

Instead, you can declare the async function inside and invoke it synchronously:

// ✅ Correct
useEffect(() => {
async function fetchData() {
  const res = await fetch("/api");
  const data = await res.json();
  setData(data);
}

fetchData();
}, []);

It allows you to keep your effect “synchronous” in the eyes of React while being able to use await inside of it. It is a good practice to use it when working with useEffect with async actions or fetching from an API.

Mistake #4: Forgetting Cleanup Functions

When a component re-renders or gets unmounted, any side-effects you create must be cleaned up – otherwise you are going to leave memory leaks, orphaned event listeners, or zombie subscriptions. React manages this using the cleanup function that your effect can return. Forgetting it is one of the most basic useEffect traps you can get into.

// ❌ Missing cleanup: event listener stays active
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);

// ✅ Proper cleanup
useEffect(() => {
window.addEventListener("resize", handleResize);


return () => window.removeEventListener("resize", handleResize);
}, []);

The cleanup function runs when the component unmounts or every time before re-running the effect. Following this pattern is key to cleaning up side effects in useEffect—especially a subscription, interval, or socket and preventing the issues mentioned above.

Mistake #5: Misunderstanding useEffect vs useLayoutEffect

Both hooks are running after render, but the timing is different. useLayoutEffect runs synchronously after all DOM mutations but before the browser paints. useEffect fires after the paint, asynchronously. If you want to read the layout or measure the DOM, use useLayoutEffect. Otherwise, use useEffect for optimal performance.

useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setSize(rect);
}, []);

📌 Pro Tip: useLayoutEffect is good for working with layouts and animations, but if you overuse it, you can block rendering and cause jank. 

Do benchmark if unclear – this is a part of React useEffect best practices for performance.

Mistake #6: Overusing useEffect

One common subtle mistake is developers attempting to use useEffect for logic that really shouldn’t be there in the first place. For instance, not all state updates must be placed inside an effect. If you’re using useEffect to track a simple value or conditional updates, then you may find the components in your component tree are more complex than needed, and even cause renders they didn’t need to.

// ❌ Overuse
useEffect(() => {
if (formValue === "reset") setCount(0);
}, [formValue]);

// ✅ Better
const handleChange = (e) => {
if (e.target.value === "reset") setCount(0);
};​

​Here, the reset logic should be based on the user interaction with the form, not as a side effect of render. 

Rule of thumb: if the effect could be worked out in an event handler or directly during render, it probably shouldn’t be in useEffect.

📌 Pro Tip: Before adding a new useEffect, think if you have to synchronize (or “side-effect”) anything outside React. If you don’t, the effect may be irrelevant anyway. This way of thinking guides you away from what many developers describe as “effect soup”—components filled with too many effects that mask intent and raise maintenance costs.

Mistake #7: “Why does useEffect run twice in React 18?”

Currently, if you are using React 18 and StrictMode is enabled, you will likely see effects updated twice. It’s not a bug, they say — it’s a feature. When in development, React will mount components twice to try and detect impure side effects (any that are not idempotent or safe to re-run). The idea is that your components become more predictable when being concurrently rendered.

useEffect(() => {
console.log("Effect executed");
}, []);

In Strict Mode (development only), this will log twice. But when it runs in production, it does so once. To avoid confusion: Make your effects idempotent – you should be able to run them twice without anything exploding. Try reducing one-time setup logic (like initializing external lib or setting up listeners) outside of React if you can. For things that should happen only once, just don’t lean on effects for them; use simple flags, refs, or regular functions outside render. Understanding such behavior is useful to clear away “phantom” bugs that only manifest in the development builds.

Mistake #8: Ignoring Performance Impact

Even correctly written effects can harm performance if they are too frequently triggered. Every re-render that ends up running a costly effect will add to the CPU load and reduce UX. A typical use case is event listeners or computations that have ephemeral dependencies.

// ❌ Recreates listener on every render
useEffect(() => {
const handleScroll = () => console.log(window.scrollY);
window.addEventListener("scroll", handleScroll);
​
return () => window.removeEventListener("scroll", handleScroll);
});

Each render adds a new listener because there is no dependency array. 

The fix is simple: 

// ✅ Stable listener
useEffect(() => {
const handleScroll = () => console.log(window.scrollY);
window.addEventListener("scroll", handleScroll);
 

 return () => window.removeEventListener("scroll", handleScroll);
}, []); // runs once

If you have effects that need things from props or use callbacks, try to wrap those calls inside useCallback or memoize derived data using useMemo. This approach prevents duplicate work and is idiomatic of how you should use React’s useEffect dependencies array.

Mistake #9: Not Realizing that useEffect Can Return Too Early or Too Late

Timing errors are more difficult to spot as the effect still “works,” though not at the anticipated time. For instance, modifying the DOM before React has finished painting may produce flickers, or reading from it too late may introduce a lag.

// ❌ Updates DOM too late
useEffect(() => {
ref.current.scrollIntoView();
}, []);

If you need to do this before your browser paints, useLayoutEffect is the way to go:

// ✅ Pre-paint adjustment
useLayoutEffect(() => {
ref.current.scrollIntoView();
}, []);

Then there’s knowing when to use useEffect vs useLayoutEffect so that you can find the right balance between visual correctness and performance. 

As a rule: Apply useEffect for data fetching, subscriptions, or manual DOM manipulations. Prefer useLayoutEffect for measurements or DOM manipulations.

Mistake #10: Failing to Manage Effect Dependencies in a Dynamic Way

Developers tend to think of the dependency arrays as static lists, but in some cases, they can be dynamic (e.g., an array of filters or parameters). These are not always deducible by React’s linter. If you happen to omit a variable that has changed, then you’ll have old data lying around.

// ❌ Missing dependency
useEffect(() => {
fetchData(filters.join(","));
}, []);

// ✅ Correct
useEffect(() => {
fetchData(filters.join(","));
}, [filters]);​

This is the case when dependencies are objects/arrays, you can consider memoizing them before passing to useEffect:

const memoizedFilters = useMemo(() => filters, [filters]);

useEffect(() => {
fetchData(memoizedFilters);
}, [memoizedFilters]);

This avoids running without need to, while also keeping the effect in line with the latest data.

✅ Best Practices in Writing Effects You Can Trust

You know by now what not to do. So, in summary, if you are to sum up a couple of frontend services things, what you should do when writing code with the useEffect React hook:

  • Keep effects pure: do not mutate data outside of the control flow of React. ❌
  • Always declare what you depend on: trust me, the linter is your friend. ✅
  • Cleanup and unsubscribe: clear timers, abort pending requests. ✅
  • Don’t use async directly in useEffect: create an inner async function. ❌
  • Don’t abuse it: synchronize only while interacting with external systems. ❌
  • Use in combination with other hooks: useCallback, useMemo, and useRef can help stabilize dependencies. ✅
  • Graceful error handling: Wrap your async calls with try…catch so that you don’t end up in unhandled rejections. ✅
  • Testing: check the different moments when the useEffect should run (mounting, cleanup, and re-run logic) using React Testing Library. ✅
  • Watch performance: Too many re-runs are a slow death in large components. ❌

Cleaning Up Side Effects in useEffect

Anything an effect sets up, it should also tear down.

Missing cleanup functions not only leak resources but can also cause duplicated listeners or timers when the component re-renders.

A real-world React useEffect cleanup pattern with a WebSocket connection is here:

useEffect(() => {
const socket = new WebSocket("wss://example.com");

socket.onmessage = (msg) => console.log("Message:", msg.data);
socket.onerror = (err) => console.error("Error:", err);

// Cleanup
return () => socket.close();
}, []);

This way, the socket connection is closed in a clean manner when the component unmounts. 

📌 Note: Cleanup functions are called prior to the next effect, or when the component is unmounted — whichever comes first.

Debugging useEffect Errors

Even with best practices, issues may still appear—especially around dependency management.

Here are some tips to troubleshoot and fix not working / not running useEffect properly:

👉 The effect doesn’t fire: check that the dependencies exist, and the hook isn’t inside a conditional block.

👉 The effect runs too often: stabilize references with memoization (useCallback, useMemo).

👉 The effect never cleans up: check that the cleanup function is actually returned from the useEffect.

You’re seeing stale data: make sure you’re reading the current state or props in the effect, not some old value from a closure.

During debugging, print the dependencies to the console:

useEffect(() => {
console.log("Dependencies changed:", deps);
}, [deps]);

It is a simple, easy-to-use tool that can help put the kibosh on those uninvited re-runs.

Conclusion

In conclusion, although it is one of the most powerful hooks of React, useEffect is also one of the hooks that developers still understand the least. It provides direct access to side effects, which gives you one-to-one communication between the virtual world of custom React development services, the client-side, and any external API. Unfortunately, it is also the source of the most React useEffect errors, with the most common caused by missing dependencies, forgotten cleanups, and misreading time. 

Keeping effects pure, managing dependencies as cops, and moving the garbage out as soon as they are no longer needed is a matter of good lifecycle care of your component. The resulting aggregate function is now clean, and the removed effects will be run only once when the component is unmounted. 

That’s it for the cleanup. It’s time to do something! 

Take out the listing of your component in the current form. Find effects that download and fill in data from the API, subscribe to events, or just manage a timer. Implement the same principles in each, and the behavior and absence of bugs in your applications will become clearly more predictable.