Blog #3: Infinite Loops – When useEffect turns the browser into a 'furnace'
The story of a 'harmless' line of code that made the laptop's fan sound like a tractor and the console log fly faster than the speed of light.
There's a moment every React developer has experienced: You just saved a file, the browser auto-refreshes, and suddenly... your laptop's cooling fan starts roaring like a jet engine. Your mouse starts lagging, the screen freezes, and when you try to open the Console Log, you see millions of notifications flowing past so fast you can't even read them.
Congratulations, you've just created an Infinite Loop with useEffect.
I once "proudly" crashed the whole QA team's browsers during a demo just because of a misplaced square bracket. It was a costly lesson in "thinking I understood React."
1. The Essence of Chaos: Referential Equality
To understand why useEffect runs forever, we have to talk about how React compares data. When you pass a dependency array to useEffect, React compares each element with its old value using the === operator.
The problem is: [] === [] is always false. {} === {} is also always false.
In JavaScript, reference types (Object, Array, Function) always create a new memory address every time the component re-renders. And that is the spark for infinite loops.
2. The Problem: Data fetching and the "Object Dependency" trap
Our system at the time had a filter function for a course list. I wanted to call the API to get new data whenever the user changed the filters. The code looked very "clean" like this:
// The "modern" code that cost me dearly
const [data, setData] = useState([]);
const filters = { category: 'frontend', level: 'beginner' }; // Terrible: Initializing object inside the component
useEffect(() => {
fetchData(filters).then(res => setData(res));
}, [filters]); // React: "Oh, new filters? Run it!" -> setData -> Re-render -> new filters -> Run again...
Everything looked very logical on the surface. But because filters is an object that is recreated on every render, React mistakenly thinks the filters have changed and triggers useEffect. After fetching, I called setData, causing the component to re-render, creating a new filters... and just like that, my app fell into a bottomless black hole.
3. The Process: Debugging in the "Eye of the Storm"
When the browser freezes, the first thing I do isn't reading the code but... closing that tab as fast as possible to save the CPU. Then, I added a "holy" log line to confirm my suspicion:
console.log('useEffect triggered at:', Date.now())
If the number of logs increases by thousands in just 1 second, I knew I was in trouble.
I tried using JSON.stringify(filters) as a dependency—a "hacky" way I found on Stack Overflow. It actually worked, but I felt something was very wrong. It felt like using duct tape to fix a burst water pipe.
The Senior-level fix: I realized I needed to stabilize the reference of filters. Either move it out of the component (if it's a constant) or use useMemo. However, the best way is often breaking the dependency.
// Sustainable solution
useEffect(() => {
// Instead of depending on the whole object, only depend on primitive values
fetchData(filters.category, filters.level).then(res => setData(res));
}, [filters.category, filters.level]);
4. Counter-argument: Sometimes we are too obsessed with useEffect
Looking back, I realized I used to be "addicted" to useEffect. I used it for everything: from calculating data, synchronizing state, to validating forms.
In fact, there are many things that don't need useEffect. If you can calculate a value from props or existing state right during render, do that. Don't force React to run an extra loop of logic after it has already drawn the UI. It's a waste and also where many bugs arise.
5. Junior vs Senior: Difference in responsibility
A Junior developer often uses // eslint-disable-next-line react-hooks/exhaustive-deps to disable plugin warnings. That's extremely dangerous. It's like removing the battery from a fire alarm because it's too loud when you're cooking.
A Senior developer sees ESLint warnings as "advice from a wise friend." If it reports a missing dependency, they don't ignore it—they find a way to include it safely (using useCallback, useMemo, or restructuring the logic).
6. Lessons Learned
Infinite loops aren't as scary as not understanding the mechanism of the tool you're using.
- Rule #1: Always prioritize primitive references (string, number, boolean) in the dependency array.
- Rule #2: If you must use Objects/Arrays, make sure they are thoroughly memoized.
- Rule #3: If you find yourself writing too many
useEffectinstances, your state structure might be problematic.
Nowadays, whenever a laptop's cooling fan gets loud, I don't panic anymore. I just smile, knowing I've found another way... not to crash an app.
Getting code to run is good, but getting it to run the right number of times is the absolute peak.
Series • Part 3 of 50