useEffect Patterns

Difficulty: Intermediate

useEffect becomes truly powerful when applied to real-world patterns like data fetching, event subscriptions, and timers. Each pattern has its own set of best practices and pitfalls. Understanding these patterns is essential for building robust React applications that handle asynchronous operations correctly.

Data fetching is the most common useEffect pattern. When fetching data based on props or state, you need to handle race conditions - situations where an older request completes after a newer one. The standard approach uses a boolean flag (often called 'ignore' or 'cancelled') that the cleanup function sets to true. When the fetch completes, it checks this flag before updating state. Modern applications can also use AbortController to actually cancel the HTTP request.

Event subscriptions follow a consistent pattern: subscribe in the effect body and unsubscribe in the cleanup function. This applies to DOM events (window resize, scroll), WebSocket connections, browser APIs (IntersectionObserver, ResizeObserver), and custom event emitters. The key rule is that every subscription must have a matching unsubscription in the cleanup.

Timers (setTimeout, setInterval) are another frequent pattern. setTimeout is straightforward - set it in the effect, clear it in cleanup. setInterval requires more care because the callback closes over state values. Using the functional updater form of setState (e.g., setCount(c => c + 1)) avoids stale closure issues with intervals.

A common advanced pattern is debouncing or throttling effects. For example, when implementing search-as-you-type, you want to delay the API call until the user stops typing. This combines setTimeout with cleanup: each keystroke clears the previous timeout and sets a new one.

Code examples

Data fetching with race condition handling

function UserData({ userId }) {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;
    setLoading(true);

    console.log('Fetching data for user:', userId);

    // Simulate async fetch
    const fetchData = () => {
      const result = { id: userId, name: 'User ' + userId };
      if (!cancelled) {
        setData(result);
        setLoading(false);
        console.log('Data loaded:', result.name);
      } else {
        console.log('Fetch cancelled for user:', userId);
      }
    };

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [userId]);

  console.log('Render - loading:', loading, 'data:', data ? data.name : 'null');
  return null;
}

UserData({ userId: 1 });

The cancelled flag prevents state updates if the component unmounts or the userId changes before the fetch completes. This prevents the 'Can't perform a React state update on an unmounted component' warning.

Window event subscription

function WindowSize() {
  const [width, setWidth] = React.useState(1024);

  React.useEffect(() => {
    const handleResize = () => {
      console.log('Window resized');
      setWidth(window.innerWidth || 1024);
    };

    console.log('Adding resize listener');
    window.addEventListener('resize', handleResize);

    return () => {
      console.log('Removing resize listener');
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  console.log('Current width:', width);
  return null;
}

WindowSize();

The effect subscribes to window resize events on mount and cleans up the listener on unmount. The empty dependency array ensures this runs only once.

Debounced search effect

function Search({ query }) {
  const [results, setResults] = React.useState([]);

  React.useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    console.log('Setting debounce timer for:', query);

    const timeoutId = setTimeout(() => {
      console.log('Searching for:', query);
      setResults(['Result for ' + query]);
    }, 300);

    return () => {
      console.log('Clearing timer for:', query);
      clearTimeout(timeoutId);
    };
  }, [query]);

  console.log('Results:', results.length);
  return null;
}

Search({ query: 'react' });

Each time query changes, the previous timeout is cleared and a new one is set. Only the last query after 300ms of inactivity triggers the actual search.

Key points

Concepts covered

data fetching, subscriptions, timers, abort controller, race conditions