DEV Community

Cover image for You Are Using useEffect Wrong - 5 Common Mistakes and How to Fix Them (2026)
Emma Schmidt
Emma Schmidt

Posted on

You Are Using useEffect Wrong - 5 Common Mistakes and How to Fix Them (2026)

If you have ever had a bug you could not explain, infinite loops, stale data, or memory leaks, useEffect was probably involved.


Introduction

useEffect is one of the most used hooks in React. It is also one of the most misunderstood. Whether you are a solo developer leveling up your skills or a tech lead looking to Hire React.js Developers who truly understand the framework, knowing how useEffect works under the hood is non-negotiable in 2026. Even experienced developers ship bugs caused by subtle useEffect mistakes every single day. In this post, we will walk through the 5 most common useEffect mistakes, show you exactly why they break your app, and give you the correct pattern to fix each one. By the end, you will have a much clearer mental model of how useEffect actually works.


Mistake 1 - Missing the Cleanup Function

The Problem

You set up a subscription, a timer, or an event listener inside useEffect but never clean it up. This causes memory leaks and bugs that are incredibly hard to trace.

// WRONG
useEffect(() => {
  const interval = setInterval(() => {
    console.log("Tick");
  }, 1000);
}, []);
Enter fullscreen mode Exit fullscreen mode

This interval keeps running even after the component unmounts. In large apps, these leaks stack up and slow everything down.

The Fix

Always return a cleanup function that tears down whatever you set up.

// CORRECT
useEffect(() => {
  const interval = setInterval(() => {
    console.log("Tick");
  }, 1000);

  return () => {
    clearInterval(interval); // Cleanup on unmount
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

The same pattern applies to event listeners, WebSocket connections, and API subscriptions.

// CORRECT - Event listener cleanup
useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: If you set it up inside useEffect, clean it up in the return function.


Mistake 2 - Stale Closures in the Dependency Array

The Problem

You reference a variable inside useEffect but forget to add it to the dependency array. React captures the value at the time the effect runs, so you end up reading an old, stale value.

// WRONG
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // Always prints 0 - stale closure!
    setCount(count + 1); // Always sets to 1, never increments correctly
  }, 1000);

  return () => clearInterval(timer);
}, []); // count is missing from deps
Enter fullscreen mode Exit fullscreen mode

count is frozen at 0 inside the closure because it is never listed as a dependency.

The Fix

Option A - Add the dependency properly:

// CORRECT - Option A
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(timer);
}, [count]);
Enter fullscreen mode Exit fullscreen mode

Option B - Use the functional updater form of setState (best for counters and toggling):

// CORRECT - Option B (preferred for counters)
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1); // No dependency needed
  }, 1000);

  return () => clearInterval(timer);
}, []);
Enter fullscreen mode Exit fullscreen mode

Option B is cleaner because the effect does not need to re-run every time count changes.


Mistake 3 - Using an Object or Array as a Dependency

The Problem

Objects and arrays are compared by reference in JavaScript, not by value. If you create an object or array inside the component and pass it as a dependency, React sees a new reference on every render and runs the effect endlessly.

// WRONG
const filters = { category: "electronics", inStock: true };

useEffect(() => {
  fetchProducts(filters);
}, [filters]); // New object reference every render = infinite loop
Enter fullscreen mode Exit fullscreen mode

This triggers the effect on every single render, even when the values inside the object have not changed.

The Fix

Option A - Use primitive values as dependencies instead of objects:

// CORRECT - Option A
const category = "electronics";
const inStock = true;

useEffect(() => {
  fetchProducts({ category, inStock });
}, [category, inStock]); // Primitives compared by value
Enter fullscreen mode Exit fullscreen mode

Option B - Memoize the object with useMemo:

// CORRECT - Option B
const filters = useMemo(() => ({
  category: "electronics",
  inStock: true
}), []); // Stable reference

useEffect(() => {
  fetchProducts(filters);
}, [filters]);
Enter fullscreen mode Exit fullscreen mode

Mistake 4 - Fetching Data Without Handling Race Conditions

The Problem

You fetch data inside useEffect but the component re-renders (or unmounts) before the request completes. An older request finishes after a newer one and overwrites your state with stale data.

// WRONG
useEffect(() => {
  fetch(`/api/user/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data)); // Could be stale data from an old request
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

If the user clicks quickly and userId changes twice, the first slow request might resolve after the second one, setting the wrong user in state.

The Fix

Use an isCancelled flag or the AbortController API to ignore outdated responses.

// CORRECT - Using AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name === "AbortError") return; // Ignore cancelled requests
      console.error(err);
    });

  return () => {
    controller.abort(); // Cancel the request on cleanup
  };
}, [userId]);
Enter fullscreen mode Exit fullscreen mode
// CORRECT - Using isCancelled flag
useEffect(() => {
  let isCancelled = false;

  fetch(`/api/user/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!isCancelled) setUser(data); // Only update if still relevant
    });

  return () => {
    isCancelled = true;
  };
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

The AbortController approach is preferred in 2026 because it also cancels the network request itself, saving bandwidth.


Mistake 5 - Putting Too Much Logic Inside useEffect

The Problem

Developers often dump complex logic directly into useEffect, making it hard to read, test, and reuse.

// WRONG - useEffect doing too much
useEffect(() => {
  if (user && user.isLoggedIn) {
    fetch(`/api/dashboard/${user.id}`)
      .then(res => res.json())
      .then(data => {
        setDashboard(data);
        localStorage.setItem("lastVisit", Date.now());
        analytics.track("dashboard_viewed", { userId: user.id });
      });
  }
}, [user]);
Enter fullscreen mode Exit fullscreen mode

This is three separate concerns crammed into one effect. It is untestable and will cause confusing bugs when any one piece changes.

The Fix

Split each concern into its own useEffect or extract logic into a custom hook.

// CORRECT - Separate effects for separate concerns
useEffect(() => {
  if (!user?.isLoggedIn) return;
  fetchDashboard(user.id).then(setDashboard);
}, [user]);

useEffect(() => {
  if (!user?.isLoggedIn) return;
  localStorage.setItem("lastVisit", Date.now());
}, [user]);

useEffect(() => {
  if (!user?.isLoggedIn) return;
  analytics.track("dashboard_viewed", { userId: user.id });
}, [user]);
Enter fullscreen mode Exit fullscreen mode

Even better, extract it into a custom hook:

// CORRECT - Custom hook
function useDashboard(user) {
  const [dashboard, setDashboard] = useState(null);

  useEffect(() => {
    if (!user?.isLoggedIn) return;

    const controller = new AbortController();

    fetch(`/api/dashboard/${user.id}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setDashboard)
      .catch(err => {
        if (err.name !== "AbortError") console.error(err);
      });

    return () => controller.abort();
  }, [user]);

  return dashboard;
}

// Usage in component
const dashboard = useDashboard(user);
Enter fullscreen mode Exit fullscreen mode

Custom hooks make your effects reusable, testable, and far easier to read.


Quick Reference - All 5 Fixes at a Glance

Mistake Root Cause Fix
Missing cleanup Resource leak on unmount Return a cleanup function
Stale closure Missing dependency Add to deps or use functional updater
Object as dependency Reference comparison Use primitives or useMemo
Race conditions Async + re-render timing Use AbortController
Too much in one effect Poor separation of concerns Split effects or use custom hooks

Bonus - The Two Questions to Ask Before Every useEffect

Before you write any useEffect, ask yourself:

1. Does this actually need to be an effect?

A lot of things developers put in useEffect do not belong there. Derived state, event handlers, and one-time initializations often do not need useEffect at all. If you are transforming data from props or state, just compute it inline or use useMemo.

2. What cleans this up?

If you cannot answer this question, you probably have a memory leak waiting to happen. Always think cleanup first.


Conclusion

useEffect is not complicated once you understand the mental model. Every mistake above comes down to the same root cause: not thinking carefully about when the effect runs, what it depends on, and what it leaves behind.

Fix these five patterns in your codebase and you will eliminate a whole category of React bugs overnight.

Which of these mistakes have you made before? Drop a comment below. I can guarantee everyone reading has hit at least three of them. And if this saved you some debugging time, a ❤️ helps other devs find this post.


Top comments (0)