BRUCE_FEBRUCE_FE

EN/CH Mode

BRUCE_FE React Interview Notes - useEffect Execution Timing

Deep dive into React useEffect execution timing, order, and common issues. Master useEffect's operation in component lifecycle, cleanup function timing, and dependency array design to avoid infinite loops and execution skips.

影片縮圖

Lazy to read articles? Then watch videos!

Basic Execution Timing of useEffect

useEffect is a core React Hook used for handling side effects. Its execution timing is a crucial point in React's rendering process, and understanding when it runs is essential for using it correctly.

Basic execution timing is as follows:

  • 1. After initial render: Executes after the component is mounted and appears on screen
  • 2. After dependency changes: Executes after re-render caused by specified dependency changes
  • 3. Before unmount: Cleanup function (returned function) executes before component unmount
function ExampleComponent() {
  const [count, setCount] = useState(0);
  
  // Basic usage demonstration
  useEffect(() => {
    console.log('Effect executed: Component mounted or count changed');
    document.title = `Count: ${count}`;
    
    // Cleanup function
    return () => {
      console.log('Cleanup executed: Before next effect run or unmount');
    };
  }, [count]); // Depend on count changes
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Increment ({count})
    </button>
  );
}

React Render Cycle and useEffect Relationship

useEffect always executes after screen updates, this is its key characteristic:

React Render Process

1. Execute component function, calculate virtual DOM

2. Update actual DOM, screen repaint

3. Execute useEffect function

This design has important benefits:

  • 1. Doesn't block UI rendering, users can see the screen faster
  • 2. Can safely access updated DOM elements
// Simple example
function App() {
  const [name, setName] = useState('');
  
  // Executes after DOM update
  useEffect(() => {
    // Screen is updated, user has seen new content
    document.title = name || 'React App';
  }, [name]);
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

⚠️ Important: useEffect is always executedasynchronously, it triggers after the browser completes painting and won't block UI updates.

Execution Order of Multiple useEffects

When a component has multiple useEffects, their execution order follows these rules:

  • 1. Execute sequentially from top to bottom in the order they are declared
  • 2. Each useEffect's cleanup function runs before the next execution of that effect
  • 3. During unmount, cleanup functions execute in reverse order of useEffects
function MultipleEffectsExample() {
  // Effects will execute in order (after mount)
  useEffect(() => {
    console.log('First effect');
    return () => console.log('Cleanup first effect');
  }, []);
  
  useEffect(() => {
    console.log('Second effect');
    return () => console.log('Cleanup second effect');
  }, []);
  
  useEffect(() => {
    console.log('Third effect');
    return () => console.log('Cleanup third effect');
  }, []);
  
  // Log order during component unmount:
  // "Cleanup third effect"
  // "Cleanup second effect"
  // "Cleanup first effect"
}

Cleanup Function Execution Timing

The cleanup function (function returned by effect) has two execution scenarios:

  1. 1. Before next effect execution: When dependencies change, the previous effect's cleanup runs before re-executing the effect
  2. 2. During component unmount: Before the component is removed from DOM, the last effect's cleanup function executes

The main purpose of cleanup functions is to prevent memory leaks and cancel unnecessary subscriptions or operations:

function WindowWidthObserver() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    // Set up event listener
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    
    // Cleanup function - prevent memory leaks
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty dependency array, only runs on mount and unmount
  
  return <div>Window width: {width}px</div>;
}

Cleanup Function Execution Order in Multiple Renders

Initial Render

Component Render

Execute Effect A

Dependency Change

Component Re-render

Execute Effect A Cleanup

Execute New Effect A

Component Unmount

Component About to Remove

Execute Effect A Cleanup

Component Removed from DOM

Dependency Array and Execution Timing

The dependency array (second parameter) determines when useEffect re-executes:

Dependency ArrayExecution TimingUse Cases
未提供Executes after every renderNeed to synchronize something after every render
[]Only executes after mount and before unmountSetup/cleanup operations that only need to run once
[a, b]Executes after mount and when a or b changesSide effects that respond to specific data changes
function DependencyExample({ userId, filter }) {
  // 1. Executes after every render (no dependency array)
  useEffect(() => {
    console.log('Component rendered');
  });
  
  // 2. Only executes on mount and unmount (empty dependency array)
  useEffect(() => {
    console.log('Component mounted');
    return () => console.log('Component will unmount');
  }, []);
  
  // 3. Executes when userId changes (specific dependency)
  useEffect(() => {
    console.log(`Fetching data for user ${userId}`);
    fetchUserData(userId);
  }, [userId]);
  
  // 4. Executes when userId or filter changes (multiple dependencies)
  useEffect(() => {
    console.log(`Applying filter ${filter} for user ${userId}`);
    fetchFilteredData(userId, filter);
  }, [userId, filter]);
}

Common useEffect Mistakes and Solutions

1. Infinite Loop

One of the most common mistakes is updating state in useEffect without properly setting up the dependency array:

// ❌ Wrong example - Infinite loop
function InfiniteLoopExample() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // effect updates state, causing re-render, triggering effect again
    setCount(count + 1);
  }, [count]); // re-runs when count changes
  
  return <div>{count}</div>;
}

// ✅ Fix method 1 - Use functional update, remove dependency
function FixedExample1() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(c => c + 1); // functional update doesn't need count dependency
  }, []); // runs only once
  
  return <div>{count}</div>;
}

// ✅ Fix method 2 - Condition control
function FixedExample2() {
  const [count, setCount] = useState(0);
  const [hasIncremented, setHasIncremented] = useState(false);
  
  useEffect(() => {
    if (!hasIncremented) {
      setCount(count + 1);
      setHasIncremented(true);
    }
  }, [count, hasIncremented]);
  
  return <div>{count}</div>;
}

2. Missing Dependencies

Another common mistake is not including all variables used in the effect as dependencies:

// ❌ Wrong example - Missing dependency
function MissingDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // effect uses productId but it's missing from dependencies
    fetchProductData(productId, userId).then(setData);
  }, [userId]); // only depends on userId
  
  return <div>{/* Display data */}</div>;
}

// ✅ Fix method - Include all dependencies
function FixedDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetchProductData(productId, userId).then(setData);
  }, [productId, userId]); // include all dependencies
  
  return <div>{/* Display data */}</div>;
}

3. Asynchronous Issues in Side Effects

When handling asynchronous operations, race conditions may occur:

Race Condition Illustration

User quickly types search string:

Type "a"
Send request A (slower)
Type "ab"
Send request B (faster)
Request B completes
Show results for "ab" ✓
Request A completes
Overwrite with results for "a" ❌

Problem: Old request results overwrite newer ones, causing UI to display results inconsistent with user's latest input.

// ❌ Example with potential issues - Race condition 
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    fetchResults(query).then(data => {
      // ⚠️ No check here if query is still the latest
      setResults(data);
    });
  }, [query]);
  
  return <ResultsList results={results} />;
}

Using AbortController to Solve Race Conditions

How AbortController works:

When typing "a"

Create controller A

Send request A

When typing "ab"

Execute cleanup function

controller A.abort()

→ Request A cancelled ✓

Create controller B

Send request B

Result

Only request B completes

Show results for "ab"

→ Matches latest input ✓

Benefits: Automatically cancels stale requests, prevents memory leaks, and ensures UI always shows results from the latest request.

// ✅ Method: Using AbortController (cleaner)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      });
    
    return () => {
      controller.abort(); // Abort previous request
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

Handling useEffect to Avoid Memory Leaks

Incorrect useEffect cleanup can lead to memory leaks, especially when handling subscriptions, timers, event listeners, and other resources. Here are common memory leak scenarios and solutions:

Common Sources of Memory Leaks

  • Uncleaned event listeners
  • Uncancelled timers (setTimeout/setInterval)
  • Unclosed network connections or subscriptions
  • Unreleased DOM references

The correct cleanup patterns are as follows:

// Timer cleanup example
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('This is a timer');
  }, 1000);
  
  // Cleanup function
  return () => {
    clearTimeout(timer); // Clean up timer
  };
}, []);

// Event listener cleanup example
useEffect(() => {
  const handleResize = () => {
    console.log('Window size changed');
  };
  
  window.addEventListener('resize', handleResize);
  
  // Cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

Impact of Memory Leaks

Uncleaned Component: Maintains active connections even after component unmount

Component unmounted

Listeners/timers still running

Memory cannot be released

May cause app crashes

Properly Cleaned Component: Releases all resources on unmount

Component about to unmount

Execute cleanup function

Release all resources

Memory properly reclaimed

A useful technique for detecting memory leaks is to add warning logs during development:

function DataFetcher() {
  useEffect(() => {
    let isMounted = true;
    
    fetchData().then(data => {
      // Prevent setting state on unmounted component
      if (isMounted) {
        setData(data);
      }
    });
    
    return () => {
      isMounted = false;
      console.log('DataFetcher resources cleaned up'); // Development check log
    };
  }, []);
  
  // ...
}

💡 Tip: Using React 18's Strict Mode can help identify potential memory leaks as it intentionally double-mounts and unmounts components, making cleanup issues easier to spot.

🔥 Common Interview Questions

(1) When does useEffect execute, when does its cleanup function run, and what is its purpose?

Answer: useEffect executes after each render (after DOM updates). Based on the dependency array:

  • No dependency array: Executes after every render
  • Empty dependency array []: Only executes after initial render
  • Array with dependencies: Executes after initial render and when dependencies change

useEffect Execution Flow

1. 元件渲染

2. 畫面更新

3. 執行useEffect

Cleanup Function Execution Timing:

  1. Before the next effect execution (when dependencies change)
  2. During component unmount (before removal from DOM)

Cleanup Function Purpose:

  • Prevent memory leaks (cancel subscriptions, clear timers)
  • Cancel ongoing API requests
  • Remove event listeners
  • Avoid updating state on unmounted components
useEffect(() => {
  const timer = setTimeout(() => {...}, 1000);
  
  return () => {
    clearTimeout(timer); // Cleanup timer
  };
}, [dependency]);

(2) How to handle asynchronous operations in useEffect?

Answer: Key points for handling asynchronous operations:

  1. Use flag variables to prevent updating state on unmounted components
  2. Handle errors properly
  3. Handle race conditions (use only the latest result when multiple requests are made)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      });
    
    return () => {
      controller.abort(); // Abort previous request
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

Handling Race Conditions

1. User searches 'A'

→ Send request A

2. User quickly searches 'B'

→ Cancel request A

→ Send request B

3. Only show results for B

→ Prevent old data override

(3) How to handle memory leaks in useEffect

Answer: Main methods to prevent memory leaks:

  1. Use cleanup functions to release resources
  2. Cancel incomplete asynchronous operations
  3. Remove event listeners and subscriptions
  4. Use AbortController to cancel fetch requests
useEffect(() => {
  // 1. Event listener
  window.addEventListener('resize', handleResize);
  
  // 2. Timer
  const interval = setInterval(tick, 1000);
  
  
  // 3. Use AbortController to cancel fetch
  const controller = new AbortController();
  fetch(url, { signal: controller.signal });
  
  // Cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
    clearInterval(interval);
    controller.abort();
  };
}, []);

Common Sources of Memory Leaks

Uncleaned Timers

setTimeout, setInterval

Unremoved Event Listeners

addEventListener

Uncancelled Network Requests

fetch, axios

Uncancelled Subscriptions

WebSocket, Observable

Using React 18 Strict Mode can help identify memory leaks as it double-mounts and unmounts components, making cleanup issues easier to spot.