10 useEffect Anti-Patterns React Developers Keep Making

Ask any React developer which hook causes the most bugs in production and the answer is almost always the same. useEffect is responsible for an outsized share of infinite render loops, stale data on screen, memory leaks that only appear after ten minutes of use, and race conditions that only reproduce when a user's connection drops at the wrong moment. The hook itself is not the problem — the problem is that it looks simple enough to use without fully understanding the model it represents. These ten patterns show up repeatedly in code reviews, and each one has a correct alternative that is just as readable once you know it.

Anti-Pattern 1: Missing the Dependency Array Entirely

Calling useEffect without a second argument means the effect runs after every single render. For most effects, this produces an infinite loop when the effect itself triggers a state update.

// Wrong — runs after every render
useEffect(() => {
  fetch('/api/user').then(res => res.json()).then(data => {
    setUser(data); // triggers re-render → effect runs again → infinite loop
  });
});

// Correct — runs once after mount
useEffect(() => {
  fetch('/api/user').then(res => res.json()).then(data => {
    setUser(data);
  });
}, []); // empty dependency array

The empty array signals that the effect has no dependencies — it only needs to run once. Add specific values to the array only when the effect genuinely needs to re-run when those values change.

Anti-Pattern 2: Objects and Arrays in the Dependency Array

JavaScript compares objects and arrays by reference, not by value. An object literal defined inside a component body creates a new reference on every render, so React sees a changed dependency and re-runs the effect — indefinitely.

// Wrong — new object reference on every render
function UserProfile({ userId }: { userId: string }) {
  const options = { headers: { 'X-User': userId } }; // new reference each render

  useEffect(() => {
    fetch('/api/profile', options).then(/* ... */);
  }, [options]); // always "changed"
}
// Correct option 1 — move object outside the component
const defaultOptions = { headers: { 'Accept': 'application/json' } };

// Correct option 2 — useMemo to stabilise the reference
function UserProfile({ userId }: { userId: string }) {
  const options = useMemo(
    () => ({ headers: { 'X-User': userId } }),
    [userId]
  );

  useEffect(() => {
    fetch('/api/profile', options).then(/* ... */);
  }, [options]); // only changes when userId changes
}

The same problem applies to arrays, callbacks, and any other non-primitive value created during render. When you see a dependency that changes every render, the fix is to stabilise the reference, not to remove it from the dependency array.

Anti-Pattern 3: async Function Directly in useEffect

useEffect callbacks must either return nothing or return a cleanup function. An async function returns a Promise, and React does not know how to use a Promise as a cleanup function — it silently ignores it while your asynchronous code continues running after unmount.

// Wrong — useEffect receives a Promise, not a cleanup function
useEffect(async () => {
  const data = await fetchUserData(userId);
  setUser(data);
}, [userId]);
// Correct — define async function inside, call it immediately
useEffect(() => {
  async function loadUser() {
    const data = await fetchUserData(userId);
    setUser(data);
  }
  loadUser();
}, [userId]);

// Alternative — IIFE pattern
useEffect(() => {
  (async () => {
    const data = await fetchUserData(userId);
    setUser(data);
  })();
}, [userId]);

Both patterns work. The named function pattern is easier to read in more complex effects. Neither pattern handles cleanup yet — see anti-pattern 5 for the AbortController approach.

Anti-Pattern 4: No Cleanup for Subscriptions and Event Listeners

Every subscription or event listener added inside useEffect must be removed when the component unmounts. Without a cleanup function, the handler continues to fire and may call setState on an unmounted component — causing warnings in development and wasted computation in production.

// Wrong — event listener accumulates on every re-mount
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // no cleanup
}, []);

// Wrong — WebSocket never closed
useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/feed');
  ws.onmessage = (event) => {
    setMessages(prev => [...prev, JSON.parse(event.data)]);
  };
}, []);
// Correct — cleanup function removes listener and closes connection
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, [handleResize]);

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/feed');
  ws.onmessage = (event) => {
    setMessages(prev => [...prev, JSON.parse(event.data)]);
  };
  return () => {
    ws.close();
  };
}, []);

The return value of useEffect is the cleanup function. React calls it before running the effect again (on dependency change) and when the component unmounts.

Anti-Pattern 5: Race Conditions in Data Fetching

When a user types into a search field, multiple requests fire in quick succession. If request #3 resolves before request #4, the older result overwrites the newer one — the UI shows stale data while the user is looking at a result from a previous query.

// Wrong — earlier responses can overwrite later ones
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(data => setResults(data)); // could be an old response
}, [query]);
// Correct — AbortController cancels the previous in-flight request
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    });

  return () => {
    controller.abort(); // cancel request when query changes or component unmounts
  };
}, [query]);

AbortController is supported in all modern browsers without a polyfill. The cleanup function calls abort(), which causes the fetch to reject with an AbortError — which you should explicitly ignore in your catch handler.

Anti-Pattern 6: Using useEffect for Data Fetching When a Library Should

A bare useEffect fetch handles the happy path and nothing else. You still need loading state, error state, caching, deduplication, background refetching, and retry logic. Writing all of that by hand produces hundreds of lines that already exist in React Query or SWR.

// Acceptable for simple, one-off data that never needs refreshing
useEffect(() => {
  fetch('/api/config')
    .then(res => res.json())
    .then(setConfig);
}, []);
// Better for anything user-facing with real loading/error states
import { useQuery } from '@tanstack/react-query';

function ProductList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <List items={data} />;
}

Use a library when: multiple components need the same data, you need background refresh, you want request deduplication, or the user needs to see a proper loading and error state. A raw useEffect fetch is fine for one-time configuration loads or data that never changes after mount.

Anti-Pattern 7: Calling setState Unconditionally Inside useEffect

An unconditional setState inside a useEffect that lists that state value as a dependency creates a guaranteed re-render loop: the effect sets state, state change triggers the effect again, the effect sets state again.

// Wrong — infinite loop
useEffect(() => {
  setCount(count + 1); // triggers re-render → effect runs → setCount again
}, [count]);
// Correct — condition prevents the loop
useEffect(() => {
  if (count < 10) {
    setCount(count + 1);
  }
}, [count]);

// Or use the functional updater form with a ref to track prior value
useEffect(() => {
  if (prevRef.current !== data) {
    setSomeDerivedValue(transform(data));
    prevRef.current = data;
  }
}, [data]);

Before adding any setState inside a useEffect, ask whether the new value is truly derived from the effect's side effect (a network response, a DOM measurement) or whether it is derived from existing state and props. If the latter, see anti-pattern 8.

Anti-Pattern 8: Using useEffect for Derived State

Synchronising one piece of state with another piece of state through useEffect adds an extra render cycle and makes the data flow harder to follow. Derived values should be computed directly in the render function or memoised with useMemo.

// Wrong — extra render cycle, unnecessary complexity
const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);

useEffect(() => {
  setFilteredItems(items.filter(item => item.active));
}, [items]);
// Correct — compute during render, memoize if expensive
const [items, setItems] = useState<Item[]>([]);
const filteredItems = useMemo(
  () => items.filter(item => item.active),
  [items]
);

// For cheap derivations, just compute inline without useMemo
const activeCount = items.filter(item => item.active).length;

The rule is: if a value can be calculated from existing state and props at render time, it is not state. Making it state and syncing it through useEffect adds two renders where one would do.

Anti-Pattern 9: useEffect for Synchronous DOM Manipulation

useEffect fires asynchronously after the browser has painted. If you read layout properties (offsetHeight, getBoundingClientRect) and then write them back, the user sees a flash of the wrong layout followed by the correction. useLayoutEffect fires synchronously after DOM mutations but before the browser paints — it is the correct hook for DOM measurements that immediately affect visual layout.

// Wrong — causes visible layout flash
useEffect(() => {
  const el = ref.current;
  const height = el.offsetHeight;
  el.style.marginTop = `-${height / 2}px`; // centering hack
}, []);
// Correct — runs before paint, no visual flash
import { useLayoutEffect, useRef } from 'react';

useLayoutEffect(() => {
  const el = ref.current;
  const height = el.offsetHeight;
  el.style.marginTop = `-${height / 2}px`;
}, []);

useLayoutEffect has the same signature as useEffect. Use it only when you need to read or write DOM layout synchronously. For everything else — data fetching, subscriptions, timers — useEffect is correct because the asynchronous firing does not cause a visible problem.

Anti-Pattern 10: Suppressing the ESLint Exhaustive-Deps Rule

The react-hooks/exhaustive-deps ESLint rule warns when your dependency array is missing a value that the effect references. The most common response is to silence it with a comment. This comment is almost always wrong.

// Wrong — suppression hides a stale closure bug
useEffect(() => {
  sendAnalyticsEvent(userId, pageName); // pageName captured from closure
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // pageName never in deps — effect always uses the initial value
// Correct — include the dependency and understand why it is needed
useEffect(() => {
  sendAnalyticsEvent(userId, pageName);
}, [userId, pageName]); // re-fires when either changes

// If you genuinely want to fire only once and capture the initial value:
const pageNameRef = useRef(pageName);
useEffect(() => {
  sendAnalyticsEvent(userId, pageNameRef.current);
}, [userId]); // ref is stable — not needed in deps

The ESLint rule is right more than 95% of the time. When it flags something that feels wrong, the answer is almost never to suppress it — it is to reconsider whether the effect should be structured differently. Common legitimate fixes include using a ref to hold a stable reference, extracting logic into a useCallback, or splitting one large effect into two smaller ones with separate dependency arrays.

A Mental Model That Helps

Every useEffect should answer three questions before you write it: What external system am I connecting to? What happens when the dependencies change? What cleanup reverses this connection? If you cannot answer all three, the effect is not ready. The dependency array is a contract with React, not a performance optimisation — it tells React when the world has changed enough that the connection needs to be re-established.

Most of the patterns above have the same root: treating useEffect as a lifecycle method from class components rather than as a synchronisation mechanism. The shift in mental model — from "run this on mount" to "keep this external system in sync with this state" — makes it much clearer when each of these patterns is the wrong tool.

Frequently Asked Questions

How do I know when I actually need useEffect vs just putting logic in the render function?

useEffect exists specifically to synchronise your component with an external system — a WebSocket, a browser API, a third-party library, a timer, or a network request. If your logic is purely derived from props or state (computing a filtered list, formatting a number, constructing a string), it belongs directly in the render function or inside useMemo, not inside a useEffect. A simple test: if removing useEffect would break a connection to something outside React's tree, you need it. If the logic only reads from and writes to React state, you almost certainly do not.

Is useEffect being deprecated or replaced in React 19?

No. React 19 introduced the use() hook for consuming promises and context, and introduced Actions (useActionState, useFormStatus) for handling form submissions and mutations that previously required a useEffect and useState combination. These additions reduce how often you reach for useEffect for data loading and form handling. But useEffect remains the correct tool for genuine side effects: setting up subscriptions, attaching event listeners, starting timers, integrating third-party DOM libraries, and writing to the browser's localStorage or sessionStorage.

Why does React run useEffect twice in development mode?

React's StrictMode intentionally mounts, unmounts, and remounts every component in development to surface cleanup bugs. When your effect runs twice, React is checking that your cleanup function correctly reverses the effect — so that mounting fresh is identical to remounting after cleanup. If your code breaks on the second mount (duplicate subscriptions, doubled API calls, accumulated event listeners), that is a signal that your cleanup function is incomplete. The fix is always to return a cleanup function that fully reverses what the effect set up: close the WebSocket, call removeEventListener, cancel the fetch with AbortController, or clear the timer with clearTimeout.