Skip to main content

Command Palette

Search for a command to run...

useEffect in React.js [part-3]

Understanding Why useEffect Cleanup is Important: A Practical Guide

Updated
7 min read
useEffect in React.js [part-3]

Understanding cleanup functions in React's useEffect hook is crucial for building robust, production-ready applications. While effects without cleanup may appear to work initially, they can lead to serious performance degradation, memory leaks, and unexpected behavior in real-world scenarios.

The useEffect cleanup function is an optional function you can return from within the useEffect Hook that handles the cleanup of side effects to prevent memory leaks and unwanted behavior in your application.

useEffect(() => {
    // Setup the side effect
    const intervalId = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);

    // Return the cleanup function
    return () => {
      clearInterval(intervalId); // Tidy up the interval
    };
  }, []); // Empty dependency array means it runs on mount and cleans up on unmount

The cleanup function should stop or undo whatever the setup function was doing.

Purpose and When It Runs

It runs in two main scenarios:

  • Before the component unmounts (removed from the DOM).

  • Before the effect runs again due to a change in its dependencies.

This ensures that the previous effect is tidied up before a new one is initiated, preventing issues like trying to update the state of an unmounted component or having multiple intervals running simultaneously. It is very important to clean up to prevent unwanted issues at production grade application.

The Problem: Resource Leaks in Component Lifecycles

Consider a real-time dashboard that displays user activity data. The component fetches updates every 5 seconds using a polling mechanism:

function UserActivityDashboard() {
  const [activities, setActivities] = useState([]);

  useEffect(() => {
    const interval = setInterval(() => {
      fetchUserActivities().then(data => {
        setActivities(data);
      });
    }, 5000);
  }, []);

  return (
    <div>
      {activities.map(activity => (
        <ActivityCard key={activity.id} {...activity} />
      ))}
    </div>
  );
}

The Critical Flaw: When this component unmounts (user navigates away), the interval continues executing in the background. Each time the user returns to the dashboard, a new interval is created without clearing the previous one. Since this browser APIs are outside of react control it will be keep running even though component is not present on page, react knows that but browser does not.

Consequences Without Cleanup:

  • Multiple intervals run simultaneously, multiplying API requests

  • Server load increases exponentially with each component mount/unmount cycle

  • Memory consumption grows continuously as abandoned intervals accumulate

  • State updates may be attempted on unmounted components, triggering React warnings

  • API rate limits may be exceeded, causing service degradation

  • Increased infrastructure costs from unnecessary network traffic

The Solution: Implementing Cleanup Functions

The cleanup function returned from useEffect executes when the component unmounts or before the effect runs again, ensuring proper resource disposal:

function UserActivityDashboard() {
  const [activities, setActivities] = useState([]);

  useEffect(() => {
    const interval = setInterval(() => {
      fetchUserActivities().then(data => {
        setActivities(data);
      });
    }, 5000);

    // Cleanup function - runs on unmount
    return () => {
      clearInterval(interval);
    };
  }, []);

  return (
    <div>
      {activities.map(activity => (
        <ActivityCard key={activity.id} {...activity} />
      ))}
    </div>
  );
}

The cleanup function ensures that when the component unmounts, the interval is properly cleared, preventing the accumulation of background processes.

Common Scenarios Requiring Cleanup

1. WebSocket Connections

WebSocket connections maintain persistent, bidirectional communication channels between client and server:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/live');

  ws.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };

  return () => {
    ws.close();
  };
}, []);

What This Does: The effect establishes a WebSocket connection and registers a message handler. When messages arrive from the server, they're added to the existing messages array using the functional form of setState (prev => [...prev, event.data]), which ensures we're working with the most current state.

Consequences Without Cleanup:

  • Socket connections remain open indefinitely, consuming server resources

  • The server continues sending messages to disconnected clients

  • Multiple socket connections accumulate for the same user session

  • Message handlers attempt to update state on unmounted components

  • Server-side connection limits may be reached, affecting all users

  • Memory leaks occur from retained message handlers and accumulated data

  • Network bandwidth is wasted on messages sent to inactive connections


2. Event Listeners

DOM event listeners attach handlers to global objects like window or document:

useEffect(() => {
  const handleResize = () => {
    setWindowWidth(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);

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

What This Does: The effect registers a resize event listener on the window object. The handler captures the current window width and updates component state. The cleanup function removes the listener when the component unmounts.

Consequences Without Cleanup:

  • Event listeners accumulate with each component mount, creating duplicates

  • All registered handlers execute on every event, causing redundant processing

  • Memory leaks occur as handlers maintain references to unmounted components

  • Performance degrades as the number of active listeners grows

  • setState calls on unmounted components trigger console warnings

  • Browser DevTools show increasing numbers of event listeners over time

  • Application responsiveness decreases due to excessive event handling


3. Asynchronous Operations with State Updates

Async operations may complete after a component has unmounted, attempting to update non-existent state:

useEffect(() => {
  let isCancelled = false;

  async function loadUser() {
    const user = await fetchUser(userId);
    if (!isCancelled) {
      setUser(user);
    }
  }

  loadUser();

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

What This Does: The effect initiates an async data fetch and uses a cancellation flag to track whether the component is still mounted. The cleanup function sets this flag to true, preventing state updates if the fetch completes after unmounting. When userId changes, the cleanup runs before the new effect, canceling the previous request.

Consequences Without Cleanup:

  • React warnings: "Can't perform a React state update on an unmounted component"

  • Race conditions where older requests overwrite newer data

  • Unnecessary network requests continue processing despite irrelevant results

  • Memory isn't freed properly as callbacks hold references to unmounted components

  • Application state becomes inconsistent when stale data overwrites current state

  • Debugging becomes difficult due to non-deterministic state updates

  • User experience suffers from UI flickering as outdated data briefly appears


4. Subscriptions (e.g., Firebase, Redux)

External data subscriptions require explicit unsubscription to prevent leaks:

useEffect(() => {
  const unsubscribe = firestore
    .collection('messages')
    .onSnapshot(snapshot => {
      const messages = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data()
      }));
      setMessages(messages);
    });

  return () => {
    unsubscribe();
  };
}, []);

What This Does: The effect creates a real-time subscription to a Firestore collection. The onSnapshot method returns an unsubscribe function, which we store and call during cleanup to terminate the subscription.

Consequences Without Cleanup:

  • Active subscriptions continue consuming backend resources

  • Real-time updates trigger state changes on unmounted components

  • Database read operations continue, increasing billing costs

  • Multiple subscriptions to the same data source accumulate

  • Server maintains unnecessary open connections

  • Memory leaks from retained snapshot listeners and callbacks

  • Network bandwidth wasted on updates for inactive UI components


5. Animation Frames

RequestAnimationFrame creates render loops that must be canceled:

useEffect(() => {
  let animationId;

  const animate = () => {
    setPosition(prev => ({
      x: prev.x + velocity.x,
      y: prev.y + velocity.y
    }));
    animationId = requestAnimationFrame(animate);
  };

  animationId = requestAnimationFrame(animate);

  return () => {
    cancelAnimationFrame(animationId);
  };
}, [velocity.x, velocity.y]);

What This Does: The effect creates a recursive animation loop using requestAnimationFrame, updating position on every frame. The cleanup function cancels any pending animation frame when the component unmounts or when velocity dependencies change.

Consequences Without Cleanup:

  • Animation loops continue running invisibly in the background

  • CPU usage remains high even when animations aren't visible

  • Battery drain on mobile devices due to constant rendering

  • Multiple animation loops run simultaneously, compounding performance issues

  • Frame rate decreases as orphaned animations accumulate

  • Browser becomes unresponsive under the load of hundreds of animation frames

  • Thermal throttling may occur on devices from sustained high CPU usage


Best Practices

Rule of Principle: If an effect creates a resource, establishes a connection, registers a listener, or initiates an ongoing process, it must include cleanup logic. The cleanup function should reverse or terminate whatever the effect established.

Testing Cleanup: In development, React's Strict Mode intentionally mounts components twice to expose missing cleanup logic. Effects without proper cleanup will exhibit doubled behavior, making issues immediately visible.

Pattern Recognition: Look for these patterns that always require cleanup:

  • setInterval / setTimeoutclearInterval / clearTimeout

  • addEventListenerremoveEventListener

  • new WebSocket()ws.close()

  • .subscribe().unsubscribe() or returned unsubscribe function

  • requestAnimationFramecancelAnimationFrame

  • Async operations → cancellation flags or AbortController

Proper cleanup ensures applications remain performant, predictable, and maintainable as they scale in complexity and user engagement.

More from this blog

V

Viewport

30 posts