BeginnerReact Concepts

Lifecycle of a Reactive Effect in React – Mastering useEffect

Understanding the lifecycle of useEffect is key to writing reliable React code. This article walks through when effects run, how cleanups work, and how dependencies control the reactivity of your components.

By Rudraksh Laddha

Introduction

In my one of the React project, our production app was crashing every few hours. Users complained about memory leaks, infinite API calls, and mysterious re-renders. The culprit? I fundamentally misunderstood how useEffect works.

After debugging countless useEffect issues across 50+ React applications, I've learned that understanding the reactive effect lifecycle isn't just about knowing when hooks run—it's about predicting and controlling exactly what happens in your app.


The Mental Model That Changed Everything

Most developers think of useEffect as "componentDidMount with extras." That's wrong and expensive.

Here's the mindset shift: useEffect is React's way of synchronizing your component with external systems. Every time your component's data changes, React asks: "Does this effect need to re-sync?"

Think synchronization, not lifecycle events. This mental model will save you hours of debugging.


The Real useEffect Lifecycle (From Production Experience)

useEffect(() => {
  // Setup phase: Connect to external system
  const subscription = api.subscribe(userId, onDataUpdate);
  
  return () => {
    // Cleanup phase: Disconnect from external system
    subscription.unsubscribe();
  };
}, [userId]); // Dependency: When to re-sync

This isn't just "mount and unmount." Every dependency change triggers a complete cleanup → setup cycle. Understanding this prevents 90% of useEffect bugs.


Phase 1: Initial Synchronization

After your component renders for the first time, React runs your effect. No exceptions.

useEffect(() => {
  // This ALWAYS runs after first render
  console.log("User just landed on the page");
  trackPageView('/dashboard');
}, []);

I used to put this logic directly in the component body. Bad idea—it runs during render, blocking the UI.


Phase 2: Re-synchronization (The Tricky Part)

When dependencies change, React doesn't just run your effect again. It runs cleanup first, then setup. Always.

useEffect(() => {
  console.log("Setting up connection for user:", userId);
  const connection = websocket.connect(`/users/${userId}`);
  
  return () => {
    console.log("Cleaning up connection for user:", userId);
    connection.close();
  };
}, [userId]);

// Sequence when userId changes from 'alice' to 'bob':
// 1. "Cleaning up connection for user: alice"
// 2. "Setting up connection for user: bob"

This cleanup-then-setup pattern prevents resource leaks. I learned this the hard way when our app opened 100+ websocket connections simultaneously.


Phase 3: Final Cleanup

When your component unmounts, React runs cleanup one final time. Miss this, and you'll have memory leaks.

useEffect(() => {
  const handleScroll = () => {
    throttledScrollHandler();
  };
  
  window.addEventListener('scroll', handleScroll);
  
  // Critical: Remove listener when component disappears
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

Pro tip: If you're adding event listeners, timers, or subscriptions, you need cleanup. No exceptions.


Dependency Arrays: The Source of 80% of useEffect Bugs

Empty Array []: Run Once, Sync Never

useEffect(() => {
  // Perfect for: API calls, event listeners, timers that never change
  const savedTheme = localStorage.getItem('theme');
  setTheme(savedTheme || 'light');
}, []);

Specific Dependencies [value]: Re-sync When Value Changes

useEffect(() => {
  // Re-fetch user data whenever userId changes
  if (!userId) return;
  
  fetchUserProfile(userId)
    .then(setProfile)
    .catch(handleError);
}, [userId]); // Only userId, nothing else

The dependency array isn't a suggestion—it's a contract. Include every variable from component scope that your effect uses.

No Dependencies: Re-sync on Every Render (Usually Wrong)

useEffect(() => {
  // This runs after EVERY render - usually a performance killer
  console.log("I run way too often");
});

I've seen this crash production apps. Only use it for debugging or very specific edge cases.


Mental Model: Synchronization Flow

Component renders with new data
     ↓
React checks: "Did dependencies change?"
     ↓
If YES: Cleanup old effect → Run new effect
If NO: Skip effect
     ↓
Component might re-render (state updates, etc.)
     ↓
Repeat cycle
     ↓
Component unmounts → Final cleanup

This flow explains why you sometimes see "double effects" in development mode—React is testing your cleanup logic.


Production War Stories: Common Pitfalls I've Debugged

Bug Pattern What Goes Wrong My Solution
Missing cleanup in intervals Multiple timers stack up, causing jank Always clearInterval in cleanup
Stale closures in callbacks Effect uses old state values Include all used variables in deps
State updates causing infinite loops Effect updates state that triggers effect Use functional state updates or refs
Race conditions in async effects Old API responses overwrite newer ones Use AbortController or ignore flag

Real-World Example: Building a Robust Data Fetcher

useEffect(() => {
  // Handle the "no user selected" case
  if (!userId) {
    setProfile(null);
    return;
  }
  
  let ignore = false; // Prevent race conditions
  
  const fetchProfile = async () => {
    try {
      setLoading(true);
      const response = await api.getUserProfile(userId);
      
      // Only update if this effect hasn't been cleaned up
      if (!ignore) {
        setProfile(response.data);
        setError(null);
      }
    } catch (err) {
      if (!ignore) {
        setError(err.message);
        setProfile(null);
      }
    } finally {
      if (!ignore) {
        setLoading(false);
      }
    }
  };
  
  fetchProfile();
  
  return () => {
    ignore = true; // Cancel any pending state updates
  };
}, [userId]);

Why this pattern works in production:

  • Handles the "no data" case explicitly
  • Prevents race conditions with ignore flag
  • Manages loading states properly
  • Cleans up pending operations

Decision Framework: When to Use What

Use Case Pattern Why
One-time setup (analytics, auth) useEffect(() => {}, []) Runs once, never re-syncs
Data fetching based on props useEffect(() => {}, [id]) Re-fetch when identifier changes
Subscriptions with dynamic params useEffect(() => {}, [params]) Re-subscribe when params change
DOM manipulation after render useEffect(() => {}) Runs after every render

Advanced Questions I Get Asked

Q: Should I split multiple concerns into separate useEffect hooks?

Yes, absolutely. I prefer multiple focused effects over one complex effect. It makes dependencies clearer and cleanup easier to reason about.

Q: How do I handle async operations in useEffect?

Never make the effect function itself async. Instead, define an async function inside and call it:

useEffect(() => {
  const loadData = async () => {
    const data = await fetchUserData(userId);
    setData(data);
  };
  
  loadData();
}, [userId]);

Q: Why does my effect run twice in development?

React's Strict Mode intentionally double-invokes effects to help you catch cleanup bugs early. This only happens in development—your production app is fine.

Q: Can I conditionally run effects?

Put the condition inside the effect, not around the useEffect call. This keeps the dependency array consistent:

useEffect(() => {
  if (!shouldFetchData) return;
  
  fetchData();
}, [shouldFetchData, otherDeps]);

Q: How do I optimize effects for performance?

Three strategies I use: minimize dependencies, use refs for values that shouldn't trigger re-sync, and consider useCallback for functions passed as dependencies.


Key Insight: Effects Are About Synchronization

The biggest breakthrough in my React journey was understanding that useEffect isn't about lifecycle events—it's about keeping your component synchronized with external systems. When you think "sync," not "mount," everything clicks.

Start with this question: "What external system does my component need to stay in sync with?" The answer guides your effect design.

❤️ At Learn Virendana, we love creating high-quality React tutorials that simplify complex concepts and deliver a practical, real-world React learning experience for developers