Using the useEffect Hook in React

Before Hooks were introduced in React 16.8, handling side effects such as data fetching, subscriptions, or manual DOM manipulation primarily relied on lifecycle methods like componentDidMount and componentDidUpdate. The introduction of Hooks fundamentally changed this paradigm, with useEffect serving as the core hook for managing these side effects. Unlike the lifecycle methods in class components, the execution timing of useEffect is more flexible; it depends on a dependency array to determine whether the code runs and when it should re-run.

The useEffect hook accepts two arguments: the first is a callback function responsible for executing the side effect logic, and the second is an optional dependency array. When the dependency array is empty, the hook executes only once after the component mounts. When the dependency array contains variables, the callback function is triggered again whenever the value of these variables changes and the component updates. This mechanism allows developers to precisely control the frequency and timing of side effect execution, avoiding unnecessary redundant calculations or memory leaks.
In practical development, the most common application of useEffect is data fetching (such as calling APIs) and subscribing to event listeners. Since these operations involve network requests or interactions with external resources, they must be performed after the component renders and should not block the main thread; therefore, useEffect is the ideal choice.

For data fetching scenarios, we typically use async/await syntax within the callback to handle Promises. The key is to ensure that resources are cleaned up correctly when the component unmounts to prevent memory leaks. For example, if a user leaves the page or a component is destroyed, unsubscribed subscriptions can lead to invalid network requests continuing to execute. By returning a cleanup function, we can gracefully stop these operations.

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Execute when component mounts
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error('Failed to fetch user information', error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // Optional: Return a cleanup function to cancel requests or remove listeners
    return () => {
      console.log('Component unmounted, cleaning up resources');
    };
  }, [userId]); // Re-fetch data when userId changes
}

In the example above, the useEffect dependency array [userId] ensures that a request is only re-initiated when userId changes. If this array were removed, every state update would trigger a request, leading to performance waste; if an empty array were passed, it would execute only once on initial mount, which is suitable for one-time initialization operations.

Advanced Techniques and Best Practices

Beyond basic data fetching, useEffect is widely used in complex scenarios such as form validation, timer control, and third-party library integration. When handling timers or throttle functions, it is crucial to pay attention to cleanup logic to avoid "zombie" timers continuing to run after the component unmounts. Additionally, for side effects depending on external state (such as useState), one must carefully manage the dependency array to prevent closures from capturing old values and causing data inconsistencies.

A typical advanced application scenario is controlling the lifecycle behavior of a component, such as executing initialization logic only on the first render while skipping duplicate logic in subsequent updates. This can be achieved by passing an empty array [], or by combining it with a custom Hook like useIsMounted to ensure the validity of cleanup functions. For APIs that need to be called frequently or complex calculation logic, consider using useCallback and useMemo to optimize the stability of dependencies and reduce unnecessary re-renders.

import { useEffect, useRef } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    // Start timer
    intervalRef.current = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);

    // Return cleanup function to clear the timer
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []); // Empty array indicates execution only once on mount
}

Summary and Precautions

useEffect is an indispensable part of the React Hooks ecosystem, providing powerful side effect management capabilities that make component logic more clear and predictable. However, due to its special execution timing, developers must be extra cautious when managing dependencies and writing cleanup functions. Common mistakes include forgetting to return a cleanup function leading to memory leaks, omitting items in the dependency array causing data desynchronization, and over-relying on useEffect while ignoring the essence of React's state update mechanism.

The key to mastering useEffect lies in understanding its execution order: after the component mounts but before state updates, and whenever a dependency changes. By reasonably using dependency arrays and cleanup functions, we can build efficient and robust single-component applications. As React versions iterate, official documentation and community practices are constantly updated; developers are advised to keep up with the latest guides, flexibly adjust strategies based on project requirements, and achieve the best development experience.