HomeProjectsBlogContact

Why Your React useEffect Runs in a Loop (and How to Fix It)

An effect that fires forever, runs twice, or reads stale state. Here is what is actually happening with React's useEffect dependencies, and the patterns that make effects predictable.

Why Your React useEffect Runs in a Loop (and How to Fix It)

useEffect is probably the most misunderstood hook in React. It looks simple, then one day your network tab fills with the same request a few hundred times. Let's break down why effects loop, why they run twice, and when you shouldn't be using an effect at all.

The mental model

An effect runs after render, and then again whenever any value in its dependency array changes between renders. React compares dependencies with Object.is, which means for objects, arrays and functions it checks identity, not contents.

Keep that in mind. It explains nearly every bug below.

Cause 1: setting state the effect depends on

const [count, setCount] = useState(0)

useEffect(() => {
  setCount(count + 1)
}, [count])

Render → effect sets count → re-render → count changed → effect runs → ... forever.

Fix: ask whether this needs to be an effect at all. If it truly does, use the functional updater and remove the dependency:

useEffect(() => {
  setCount(c => c + 1)
}, [])   // runs once on mount

Cause 2: objects and arrays created during render

const filters = { status: 'active' }

useEffect(() => {
  fetchUsers(filters)
}, [filters])

filters is a new object on every render. Same contents, different identity, so the effect runs after every render. If fetchUsers sets state, you've built a loop.

Fixes, in order of preference:

  1. Depend on primitives: [filters.status].
  2. Move the object inside the effect if only the effect uses it.
  3. Memoize it: const filters = useMemo(() => ({ status }), [status]).

Cause 3: functions as dependencies

const load = () => fetchUsers(page)

useEffect(() => {
  load()
}, [load])   // new function every render

Same identity problem. Move the function inside the effect, or wrap it in useCallback with the right dependencies. Newer React tooling like the React Compiler can memoize a lot of this automatically, but understanding why still matters when something loops.

Cause 4: missing dependencies (the opposite bug)

Leaving values out of the array to "stop the loop" creates stale closures: the effect keeps using old props or state.

useEffect(() => {
  const id = setInterval(() => console.log(count), 1000)
  return () => clearInterval(id)
}, [])   // always logs the initial count

Keep the react-hooks/exhaustive-deps lint rule on. When it complains, fix the cause (identity, structure) rather than silencing it.

"Why does my effect run twice?"

In development with <StrictMode>, React intentionally mounts, unmounts and remounts components to surface effects that don't clean up properly. That's why you see two requests locally. It does not happen in production builds.

The right response isn't removing Strict Mode. It's writing effects that survive being run twice, which mostly means adding cleanup.

Fetching data correctly in an effect

useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/users?page=${page}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUsers)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err)
    })

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

The cleanup cancels the old request when page changes or the component unmounts. That fixes the Strict Mode double fetch and the race condition where a slow earlier response overwrites a newer one.

For anything beyond simple cases, a data-fetching library (TanStack Query, SWR) or your framework's server-side data loading handles caching, deduplication and retries for you. In Next.js, fetching in Server Components often removes the effect entirely.

You might not need an effect

Many effects shouldn't exist. Common cases:

  • Derived values. Don't sync fullName into state with an effect. Compute it during render: const fullName = first + ' ' + last.
  • Responding to events. If something happens because the user clicked, put the logic in the click handler, not in an effect watching state.
  • Resetting state when a prop changes. Give the component a key instead: <Profile key={userId} />.
  • Expensive calculations. Use useMemo, not an effect plus state.

Effects are for synchronising with things outside React: network requests, subscriptions, timers, browser APIs, third-party widgets. If no external system is involved, there's probably a simpler way.

Debugging checklist

  1. Log inside the effect and in the component body. How often does each run?
  2. For each dependency, ask: is it a new object or function every render?
  3. Does the effect set state that's also in its dependency array?
  4. Does it have a cleanup function, and does that cleanup actually undo what the effect did?
  5. Is the "double run" just Strict Mode in development?

If your effect is async and behaving strangely, these async/await mistakes are worth a look too.

FAQ

How do I run useEffect only once?

Pass an empty dependency array: useEffect(fn, []). In development Strict Mode it will still run twice on mount, by design, so make sure it has proper cleanup.

Why does my useEffect cause an infinite loop?

Usually because it updates state that's in its own dependency array, or because a dependency is an object, array or function recreated on every render.

Can I make the useEffect callback async?

Not directly, because the callback must return nothing or a cleanup function. Define an async function inside the effect and call it, or use promise chains as shown above.

Keep reading

More from the workbench

All articles →