- The "Just Add It to the Array" Trap
- Why Does This Happen? (The Real Answer)
- Back to Basics: Understanding useEffect (For Real This Time)
- The Infinite Loop Trap (Why Adding Functions Can Break Everything)
- The Solutions (That Actually Work)
- The Child Component Performance Trap
- My Real-World Infinite Loop Disaster
- The Even Simpler Solution
- Key Takeaways (So You Don't Spend 3 Hours Like I Did)
- The Golden Rules I Learned Today
- Final Thoughts
Three hours. Three whole hours I spent staring at this yellow squiggly line in VS Code today.
You know the one I’m talking about:
React Hook useEffect has a missing dependency: 'functionName'.
Either include it or remove the dependency array.
At first, I thought, “Easy fix! I’ll just add the function to the dependency array.”
Famous last words.
What followed was a rabbit hole of infinite loops, performance issues, and enough confusion to make me question my life choices. But by the end of it, I finally understood what React was trying to tell me.
If you’ve ever felt like throwing your laptop out the window because of this warning, this post is for you.
The “Just Add It to the Array” Trap
Let’s start with the code that started my three-hour nightmare:
useEffect(() => {
applyFilter();
}, []); // ESLint is angry about this empty array
VS Code is screaming at me: “Hey, you’re using applyFilter
but not including it in the dependency array!”
So I think, “Fine, I’ll add it”:
useEffect(() => {
applyFilter();
}, [applyFilter]); // This should fix it, right?
WRONG. Now my app is stuck in an infinite loop, my CPU fan is going crazy, and I’m more confused than ever.
Why Does This Happen? (The Real Answer)
After banging my head against the wall for way too long, I realized I needed to go back to basics. The problem isn’t with ESLint being annoying—it’s actually trying to save us from something called stale closures.
What Are Stale Closures? (In Plain English)
Imagine you write down your friend’s phone number on a piece of paper. Later, your friend changes their number, but you still have the old one written down. When you try to call them, you’re using outdated information.
That’s exactly what happens with stale closures in React—your function “remembers” old values that are no longer accurate.
A Real Example That’ll Make You Go “Oh!”
function Counter() {
const [count, setCount] = useState(0);
const logCount = () => {
console.log(count); // This captures whatever 'count' was when the function was created
};
useEffect(() => {
const timer = setInterval(logCount, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array - this is the problem!
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
What happens:
- Component loads with
count = 0
logCount
function is created, rememberingcount = 0
- User clicks button like crazy: count becomes 1, 2, 3, 10, 50…
- But
logCount
is still stuck in the past, forever logging0
It’s like that friend who still thinks it’s 2019 and keeps making pandemic jokes.
Back to Basics: Understanding useEffect (For Real This Time)
When I got completely lost, I remembered the golden rule: go back to basics.
What Is useEffect Actually Doing?
useEffect(() => {
console.log("Hello, I'm a side effect!");
}, []);
This runs once when the component loads. Think of it as saying, “Hey React, after you’re done setting up this component, run this code.”
The Dependency Array (That Second Parameter)
useEffect(() => {
console.log("Count changed:", count);
}, [count]); // This array tells React: "Run this effect when 'count' changes"
The dependency array is React’s way of knowing when to re-run your effect. It’s like telling React, “Watch these variables, and if any of them change, run my code again.”
Why ESLint Gets Mad
const sayHello = () => {
console.log("Hello!");
};
useEffect(() => {
sayHello();
}, []); // ESLint: "Um, excuse me, you're using 'sayHello' but not watching it!"
ESLint can’t read your mind. It doesn’t know if sayHello
might change between renders. To be safe, it wants you to include it in the dependency array.
But here’s where things get tricky…
The Infinite Loop Trap (Why Adding Functions Can Break Everything)
Let’s say you have this code:
const sortProducts = () => {
const sorted = [...filteredProducts].sort((a, b) => a.price - b.price);
setFilteredProducts(sorted);
};
useEffect(() => {
sortProducts();
}, [sortProducts]); // This looks innocent, but it's a trap!
Here’s what happens (the infinite loop of doom):
useEffect
runs → callssortProducts()
sortProducts()
callssetFilteredProducts()
- State changes → component re-renders
- New render creates a new version of
sortProducts
(different function reference) useEffect
sees “new”sortProducts
→ runs again- Back to step 1 → INFINITE LOOP 🔄
It’s like being stuck in a revolving door that never stops spinning.
The Solutions (That Actually Work)
Solution 1: useCallback (The Fancy Way)
const sortProducts = useCallback(() => {
const sorted = [...filteredProducts].sort((a, b) => a.price - b.price);
setFilteredProducts(sorted);
}, [filteredProducts]); // Only recreate when filteredProducts changes
useEffect(() => {
sortProducts();
}, [sortProducts]); // Now this is safe!
How it works:
useCallback
memoizes (remembers) the function- Only creates a new function when its dependencies change
- Breaks the infinite loop because the function reference stays stable
Pros:
- Prevents stale closures ✅
- No infinite loops ✅
- Function can be reused elsewhere ✅
Cons:
- More complex syntax
- Need to manage
useCallback
dependencies too
Solution 2: Put Logic Inside useEffect (The Simple Way)
useEffect(() => {
const sorted = [...filteredProducts].sort((a, b) => a.price - b.price);
setFilteredProducts(sorted);
}, [filteredProducts]); // Much cleaner!
How it works:
- No external function to worry about
- Clear what the effect depends on
- Simple and straightforward
Pros:
- Super simple ✅
- No
useCallback
needed ✅ - Clear dependencies ✅
Cons:
- Can’t reuse the logic elsewhere
- Might get messy for complex operations
The Child Component Performance Trap
Here’s something that confused me for ages: “Why do I need useCallback
when passing functions to child components?”
Let me show you with an example that’ll make it click:
Without useCallback (Performance Killer)
const ProductList = () => {
const [filter, setFilter] = useState('');
const [products, setProducts] = useState([]);
// This function gets recreated on EVERY render
const handleAddToCart = (productId) => {
console.log('Adding to cart:', productId);
};
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search products..."
/>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAddToCart={handleAddToCart} // New function every time!
/>
))}
</div>
);
};
const ProductItem = React.memo(({ product, onAddToCart }) => {
console.log('Rendering:', product.name); // This logs way too much!
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
);
});
What happens:
- User types in search box
filter
state changes → component re-rendershandleAddToCart
gets recreated (new function reference)- ALL
ProductItem
components re-render because they got “new” props - Performance dies a slow death 💀
With useCallback (Performance Champion)
const ProductList = () => {
const [filter, setFilter] = useState('');
const [products, setProducts] = useState([]);
// This function only changes when we want it to
const handleAddToCart = useCallback((productId) => {
console.log('Adding to cart:', productId);
}, []); // Empty dependencies = never recreate
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search products..."
/>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAddToCart={handleAddToCart} // Same function every time!
/>
))}
</div>
);
};
What happens now:
- User types in search box
filter
state changes → component re-rendershandleAddToCart
keeps the same referenceProductItem
components DON’T re-render (props haven’t changed)- App stays fast and responsive! 🚀
My Real-World Infinite Loop Disaster
Let me share the actual code that broke my brain today:
// This was causing the infinite loop
const sortProducts = useCallback(() => {
let fpCopy = [...filteredProducts];
switch (sortOption) {
case "low-high":
setFilteredProducts(fpCopy.sort((a, b) => a.price - b.price));
break;
case "high-low":
setFilteredProducts(fpCopy.sort((a, b) => b.price - a.price));
break;
default:
applyFilter(); // This also calls setFilteredProducts
break;
}
}, [filteredProducts, sortOption, applyFilter]); // filteredProducts is a dependency!
useEffect(() => {
sortProducts();
}, [sortProducts]);
The problem:
sortProducts
depends onfilteredProducts
sortProducts
changesfilteredProducts
- Changing
filteredProducts
recreatessortProducts
- New
sortProducts
triggersuseEffect
again - INFINITE LOOP 🔄
The Fix That Saved My Sanity
const sortProducts = useCallback((productsToSort) => {
let fpCopy = [...productsToSort];
switch (sortOption) {
case "low-high":
return fpCopy.sort((a, b) => a.price - b.price);
case "high-low":
return fpCopy.sort((a, b) => b.price - a.price);
default:
return productsToSort;
}
}, [sortOption]); // Only depends on sortOption now!
useEffect(() => {
setFilteredProducts(prevProducts => sortProducts(prevProducts));
}, [sortProducts]);
What I changed:
- Made
sortProducts
a pure function (takes input, returns output) - Removed
filteredProducts
from dependencies - Used functional state update to work with previous state
- Broke the infinite loop! 🎉
The Even Simpler Solution
After all this complexity, I realized I could just combine everything into one effect:
useEffect(() => {
// Apply filters first
let productsCopy = products.slice();
if (selectedCategories.length > 0) {
productsCopy = productsCopy.filter(item =>
selectedCategories.includes(item.category)
);
}
if (selectedTypes.length > 0) {
productsCopy = productsCopy.filter(item =>
selectedTypes.includes(item.type)
);
}
// Then apply sorting
switch (sortOption) {
case "low-high":
productsCopy = productsCopy.sort((a, b) => a.price - b.price);
break;
case "high-low":
productsCopy = productsCopy.sort((a, b) => b.price - a.price);
break;
default:
// Keep original order
break;
}
setFilteredProducts(productsCopy);
}, [products, selectedCategories, selectedTypes, sortOption]);
Sometimes the simplest solution is the best one.
Key Takeaways (So You Don’t Spend 3 Hours Like I Did)
- ESLint warnings exist for a reason – they prevent real bugs, not just annoy you
- Stale closures are real – functions can “remember” old values
- useCallback prevents infinite loops – when you need to pass functions around
- Sometimes simple is better – don’t overcomplicate with multiple effects
- Pure functions are your friend – they don’t cause side effects
The Golden Rules I Learned Today
✅ Use useCallback
when passing functions to child components ✅ Use useCallback
when functions are dependencies of other hooks ✅ Don’t use useCallback
for simple functions that aren’t passed around ✅ Consider moving logic inside useEffect
for simple cases ✅ Test your components after fixing dependency warnings
Final Thoughts
That yellow line in VS Code isn’t your enemy—it’s trying to save you from bugs you didn’t even know existed. Understanding useEffect
dependencies might feel like learning a new language, but once it clicks, you’ll write much more robust React applications.
And hey, at least now when you see that warning, you won’t spend three hours pulling your hair out like I did today! 😅
Have you ever been stuck in a React infinite loop? What was your “aha!” moment? I’d love to hear your stories in the comments!
Thanks for reading! — Ranjan