Featured Article

The Mystery of the Flickering Gradient: A React useEffect Deep Dive

How a single array dependency in useEffect caused gradient backgrounds to randomly change colors, and the surprisingly simple one-line fix that solved it all.

October 8, 2025 (1mo ago)
12 min read
0 views
0 likes
Category
Intermediate
#react#useeffect#debugging#javascript#gsap#performance

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 PROBLEM

The 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 reference

Benefits:

  • ✅ 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 render

Alternative 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-deps

Option 3: Use primitive property (recommended)

}, [heroId, projectsId, burstColor, blackoutTitle, overlayProjects.length]);
// Most elegant solution for this case

Testing 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:

  1. Adding comprehensive logging
  2. Testing each scenario methodically
  3. Reading React documentation thoroughly
  4. 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:

  1. Check your useEffect dependencies carefully
  2. Look for reference-type values (arrays, objects, functions)
  3. Consider using primitive properties instead
  4. 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: overlayProjectsoverlayProjects.length

Simple. Effective. Beautiful. 🎨

CleanLove

Written by CleanLove

Frontend engineer exploring delightful motion and robust UX

Initializing application
Loading page content, please wait...