How to Avoid Infinite Loop in React useEffect

React’s useEffect hook is one of the most powerful tools in the React arsenal—but it’s also one of the easiest to misuse. One of the most frustrating problems I’ve run into while working with it is accidentally causing an infinite render loop. It doesn’t just clutter the console with errors; it can bring the entire app crashing down. The bug looks innocent at first glance, but debugging it often turns into a rabbit hole. Over time, I’ve learned a few patterns that help me avoid this issue before it sneaks in.

Be Careful with Dependencies

Every time I define a useEffect and include a dependency array, I make sure I understand what goes into it. The mistake I used to make was either leaving the dependency array empty when it shouldn’t be—or worse, including a value that keeps changing on every render. When the dependency array includes something unstable like a new object or array, it guarantees the useEffect will keep firing.

For example:

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

If filters is being created inside the component (like const filters = { type: 'new' };), then a new object is created on each render, making React think it changed. I learned to either memoize such values using useMemo, or define them outside of the render scope.

Avoid Updating State Inside Effect Unconditionally

It took me some time to realize that updating state inside useEffect without a proper condition is a direct invitation for an infinite loop. I once wrote this:

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

At first glance, it looked logical—I wanted to increment the counter every time it changed. But what I didn’t realize was this setup keeps increasing the count forever. count changes, the effect runs, it updates count, and that again triggers the effect. I had to rethink the logic entirely.

If I ever do need to react to a value change and update the state based on it, I ensure I use conditions that stop unnecessary updates. Sometimes wrapping it in an if block is all that’s needed.

Debounce or Throttle Changes

There are times when the values I’m watching change rapidly, like user inputs or window dimensions. In such cases, triggering effects immediately after every change is wasteful, and worse—it may cause repeated state updates and loops. That’s when I reach for debouncing.

Using a library like lodash or a custom debounce utility inside the effect can help:

useEffect(() => {
  const handler = setTimeout(() => {
    updateFilteredList(searchTerm);
  }, 300);
  return () => clearTimeout(handler);
}, [searchTerm]);

Adding this delay ensures my updates happen after the user stops typing, and not on every keystroke. It gave me a smoother UI and fixed the loop I didn’t know I was causing.

Use useRef for Mutable Values

Not every value that changes needs to go in useEffect’s dependency array. This was a subtle realization. When I wanted to track a value across renders but didn’t want its changes to trigger effects, I switched to useRef.

Instead of:

useEffect(() => {
  logPosition(cursorPosition);
}, [cursorPosition]);

I used:

const positionRef = useRef();
positionRef.current = cursorPosition;

This allowed me to use the current value in logic without making the effect dependent on it. It’s an elegant way to sidestep unnecessary renders while still accessing updated values.

Memoize Functions with useCallback

Passing inline functions as dependencies is another common pitfall. I ran into this when using custom handlers that were defined inside the component:

useEffect(() => {
  doSomething();
}, [handleClick]);

Since handleClick was being redefined on every render, this effect kept firing. Switching to useCallback solved it:

const handleClick = useCallback(() => {
  // logic
}, []);

useEffect(() => {
  doSomething();
}, [handleClick]);

This pattern became a staple in my codebase, especially when working with event handlers or API callbacks inside components.

Avoid Calling Setters in Render Cycle

This one hit me hard during a late-night coding session. I was computing a derived value and updating state based on it, directly during the render:

const value = computeSomething(data);
setValue(value);

This innocent-looking code triggers a render, which sets state, which triggers another render, and so on. It wasn’t even inside a useEffect. I learned the hard way to do state updates inside useEffect and not during the render cycle itself.

Now, if I need to derive and set state based on props or other values, I wrap it safely inside an effect with dependencies.

Log Dependencies Thoughtfully

Every time I find myself unsure about what’s causing an effect to keep firing, I log the dependencies. I used to think console.log was just for debugging, but in this case, it helps expose sneaky changes.

useEffect(() => {
  console.log('Dependencies changed');
}, [filters, query]);

After seeing logs firing non-stop, I could trace it back to some deeply nested value or an inline object I forgot to memoize. Debugging this way has been much more effective than relying on assumptions.

Trust the Linter

I used to ignore ESLint’s exhaustive-deps rule, brushing it off as overkill. But after being bitten by subtle bugs, I started paying attention. It highlighted missing dependencies that I overlooked or pointed out values I was including unnecessarily. Respecting that warning has led me to write cleaner and more predictable effects.

In one project where I explored query clients, I saw how deeply understanding useEffect behavior could affect even basic state updates and data fetching logic.

Final Thought

Every infinite loop I’ve encountered in useEffect has taught me something new about how React renders and how dependencies work. These bugs often feel like black holes, pulling everything down with them. But over time, they pushed me to write smarter code—code that respects React’s reactivity and doesn’t fight against it.

When I’m writing or reviewing code now, I instinctively scan for signs that could lead to a loop—like unguarded state updates, unstable dependencies, or overly eager effects. These tiny habits have saved me countless hours of debugging.

React rewards clarity, and nowhere is that more true than in useEffect. The moment I stopped treating it like a mysterious black box and started thinking through its logic, my components became far more stable and predictable.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top