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.