The Mystery of the Flickering Gradient: A React useEffect Deep Dive
The Problem
Picture this: You've built a beautiful projects section with animated gradient backgrounds. You carefully crafted the logic to randomly select a gradient color once when the page loads, then keep it stable throughout the user's session. Simple, right?
Wrong.
Despite calling initBurstColor() only once in the component lifecycle, the background kept changing colors randomly:
- Every time I saved the file (hot module reload)
- When scrolling through the page
- Sometimes even when just moving the mouse
- In development mode with React StrictMode enabled
The user experience was jarring—colors would shift unexpectedly, making the site feel buggy and unprofessional.
The Journey
First Attempt: Remove Scroll-Based Updates
My initial hypothesis was that ScrollTrigger callbacks were causing the issue. I removed all color updates from the onLeave callback:
onLeave: () => {
// Removed: updateBurstColor("ScrollTrigger.onLeave");
// Just handle pointer events now
if (heroPinSpacer) heroPinSpacer.style.pointerEvents = "none";
hero.style.pointerEvents = "none";
}Result: This fixed scrolling-related changes, but colors still flickered during hot reloads. ❌
Second Attempt: Add Initialization Guards
Maybe the effect was firing too early? I added guards to prevent updates during initialization:
let isInitializing = true;
// Later...
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isInitializing = false;
});
});Result: Helped with initial render issues, but hot reloads still caused color changes. ❌
Third Attempt: Fixed Gradient for Mobile
Perhaps mobile devices needed a simpler approach? I created a fixed gradient specifically for mobile:
const mobileFixedGradient = {
colors: ["#667eea", "#764ba2"],
gradient: "radial-gradient(140% 120% at 50% 48%, #667eea 0%, #764ba2 55%, ...)"
};Result: Mobile looked stable, but desktop still had issues. Plus, this added complexity and different behaviors across devices. ❌
Fourth Attempt: Unified Random Logic
Let's simplify everything—just pick a random color once, for all devices:
const initBurstColor = () => {
const randomIndex = Math.floor(Math.random() * colorPalettes.length);
const chosenColors = colorPalettes[randomIndex];
// Apply colors...
};
// Call once
initBurstColor();Result: Cleaner code, but the problem persisted! Colors still changed on hot reload. ❌
At this point, I was baffled. The function was only called once. What could possibly be causing it to run again?
The Breakthrough
After extensive debugging and diving into React documentation, I added logging to the useEffect:
useEffect(() => {
console.log('[DEBUG] useEffect executed!');
initBurstColor();
// ... rest of setup
return () => {
console.log('[DEBUG] useEffect cleanup!');
};
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects]);The console revealed the shocking truth:
[DEBUG] useEffect executed!
[DEBUG] useEffect cleanup!
[DEBUG] useEffect executed!
[DEBUG] useEffect cleanup!
[DEBUG] useEffect executed!
The effect was re-running constantly!
Looking at the dependency array, I spotted the culprit:
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects]);
// ^^^^^^^^^^^^^^
// THE PROBLEMThe Root Cause
JavaScript Reference Equality
In JavaScript, arrays and objects are compared by reference, not by value:
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
console.log(arr1 === arr2); // false ❌
// Even though they have identical content, they're different objects!React's useEffect uses this same === comparison to determine if dependencies have changed.
The Vicious Cycle
Here's what was happening:
1. Component mounts → useEffect runs → initBurstColor() → Color A
2. Hot module reload OR parent component re-renders
3. Parent creates new overlayProjects array (new reference)
const projects = [{...}, {...}, {...}]; // New array every render!
4. React compares: newProjects !== oldProjects
// True! They're different references
5. useEffect re-runs → initBurstColor() → Color B ❌
6. User sees color change unexpectedly
Every time the parent component re-rendered (which happens frequently in development with hot reloading), it created a new array for overlayProjects. React saw this as a dependency change and re-ran the entire effect, including initBurstColor().
Why This Happens
This is a fundamental aspect of React's component model:
function ParentComponent() {
const [count, setCount] = useState(0);
// ⚠️ This creates a NEW array every render!
const projects = [
{ title: "Project 1" },
{ title: "Project 2" }
];
return <AppleBurstPerfect overlayProjects={projects} />;
}Every time ParentComponent renders (state change, hot reload, etc.), the projects array is recreated with a fresh reference, even if the content is identical.
The Solution
The fix was embarrassingly simple—one line of code:
// Before
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects]);
// After
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects.length]);
// ^^^^^^^^^^^^^^^^^^^^Why This Works
Instead of depending on the entire array (reference type), we depend on its length (primitive type):
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
console.log(arr1.length === arr2.length); // true ✅
// Numbers are compared by value, not referenceBenefits:
- ✅ Avoids reference comparison issues
- ✅ Still responds to actual changes (when projects are added/removed)
- ✅ Works consistently across hot reloads and re-renders
- ✅ Simple and performant
React Hooks Best Practices
Safe Dependencies (Primitive Types)
// ✅ Strings
}, [userId]);
// ✅ Numbers
}, [count]);
// ✅ Booleans
}, [isActive]);
// ✅ Extract primitive properties
}, [user.id, items.length, config.apiUrl]);Dangerous Dependencies (Reference Types)
// ❌ Objects
}, [user]); // New reference each render
// ❌ Arrays
}, [items]); // New reference each render
// ❌ Functions
}, [handleClick]); // New reference each renderAlternative Solutions
Option 1: useMemo (for parent components)
function ParentComponent() {
// Stable reference across renders
const projects = useMemo(() => [
{ title: "Project 1" },
{ title: "Project 2" }
], []); // Empty deps = never recreate
return <AppleBurstPerfect overlayProjects={projects} />;
}Option 2: Remove the dependency (if truly not needed)
}, [heroId, projectsId, burstColor, blackoutTitle]);
// Just remove it if the effect doesn't need to respond to changes
// Add: eslint-disable-next-line react-hooks/exhaustive-depsOption 3: Use primitive property (recommended)
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects.length]);
// Most elegant solution for this caseTesting the Fix
After applying the fix, I verified the behavior:
| Scenario | Before | After | |----------|--------|-------| | Initial page load | Random color A | Random color A ✅ | | Hot module reload | Color changes! ❌ | Stays color A ✅ | | Scroll page | Color changes! ❌ | Stays color A ✅ | | Mouse movement | Color changes! ❌ | Stays color A ✅ | | Refresh page | New random color | New random color ✅ |
Perfect! The gradient now behaves exactly as intended:
- Randomly selected once per session
- Stable during all user interactions
- Fresh color only on explicit page refresh
Lessons Learned
1. Trust But Verify
Even if you're only calling a function once in your code, that doesn't mean it only runs once. React's re-rendering and effect system can surprise you.
2. Watch Your Dependencies
Reference-type dependencies (arrays, objects, functions) in useEffect are a common source of bugs. Always ask: "Will this reference change even if the content stays the same?"
3. Debug Systematically
The debugging process involved:
- Adding comprehensive logging
- Testing each scenario methodically
- Reading React documentation thoroughly
- Understanding the underlying JavaScript behavior
Sometimes the answer is in the fundamentals, not the fancy features.
4. Keep It Simple
The final solution was simpler than all our "clever" workarounds:
- No initialization guards
- No device-specific logic
- No complex state management
- Just one array changed to a number
Performance Impact
This fix also improved performance:
Before:
- Effect ran on every parent re-render
- Created new gradient calculations repeatedly
- Triggered unnecessary DOM updates
- Caused visual jank during interactions
After:
- Effect runs only when truly needed
- Single gradient calculation per session
- Minimal DOM operations
- Smooth, stable visuals
Conclusion
This bug taught me a valuable lesson about React's dependency system and JavaScript's reference equality. What seemed like a simple color initialization problem was actually a fundamental misunderstanding of how useEffect tracks changes.
The next time you see unexpected re-renders or behavior in React:
- Check your
useEffectdependencies carefully - Look for reference-type values (arrays, objects, functions)
- Consider using primitive properties instead
- Test thoroughly with hot reloading enabled
Sometimes the smallest bugs require the deepest understanding. And sometimes, the best fix is the simplest one.
One line of code. Hours of debugging. A lifetime of lessons learned.
Code Summary
The complete fix:
// src/components/ui/apple-burst-perfect.tsx
// Simplified initialization - no device detection needed
const initBurstColor = () => {
const randomIndex = Math.floor(Math.random() * colorPalettes.length);
const chosenColors = colorPalettes[randomIndex];
overlay.style.setProperty("--burst-c1", chosenColors[0]);
overlay.style.setProperty("--burst-c2", chosenColors[1]);
currentBurstGradient = `radial-gradient(140% 120% at 50% 48%, ${chosenColors[0]} 0%, ${chosenColors[1]} 55%, rgba(10,12,28,0.96) 92%, rgba(4,6,16,0.99) 100%)`;
syncBurstGradientToBackdrop();
};
useEffect(() => {
// ... setup code
initBurstColor(); // Called once
// ... more setup
return () => {
// cleanup
};
// 🔧 THE FIX: Use .length instead of the entire array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects.length]);That's it. One word changed: overlayProjects → overlayProjects.length
Simple. Effective. Beautiful. 🎨