AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useEffect Cleanup Function Examples: Managing Side Effects

Last updated:
useEffect Cleanup Function Examples in React

Master useEffect cleanup functions with practical examples. Learn how to prevent memory leaks, clear timers, remove event listeners, and handle subscription cleanup in React.

# useEffect Cleanup Function Examples: Managing Side Effects

Effects in React are powerful, but they come with a catch—if you don't clean up after them, you'll leak memory, accumulate event listeners, and create zombie timers. The cleanup function is how React lets you handle this responsibility. Think of it as a safety mechanism that fires right before an effect reruns or when your component gets removed from the DOM.

You'll notice this problem immediately if you've ever toggled a component on and off a few times while checking the console. Without cleanup, the number of event listeners grows, timers pile up, and your app starts behaving erratically. The good news? React makes cleanup straightforward once you understand when and why to use it.

# Table of Contents

  1. What is a Cleanup Function?
  2. How Cleanup Functions Execute
  3. Clearing Timers and Intervals
  4. Removing Event Listeners
  5. Managing Subscriptions
  6. Aborting HTTP Requests
  7. Real-World Scenarios
  8. Common Mistakes
  9. FAQ

# What is a Cleanup Function?

A cleanup function is simply a function that you return from inside your useEffect hook. React executes this function automatically at specific moments to undo or clean up the work your effect did.

Here's the core concept:

typescript
useEffect(() => {
  // Your side effect code here
  doSomething();
  
  // Return a cleanup function
  return () => {
    // This runs before the effect reruns or when component unmounts
    undoSomething();
  };
}, [dependencies]);
javascript
useEffect(() => {
  // Your side effect code here
  doSomething();
  
  // Return a cleanup function
  return () => {
    // This runs before the effect reruns or when component unmounts
    undoSomething();
  };
}, [dependencies]);

The cleanup function is optional. You only need to return one if your effect actually requires cleanup. Effects that just read data, for example, typically don't need cleanup.

# How Cleanup Functions Execute

Understanding when cleanup functions run is crucial. They execute in two specific scenarios:

Scenario 1: Before Re-execution — When your effect depends on a value that changes, React first runs the cleanup function from the previous effect, then runs the new effect.

Scenario 2: On Unmount — When your component is removed from the DOM, React runs the cleanup function to ensure no dangling resources remain.

Here's the execution order:

typescript
import { useState, useEffect } from 'react';

export function TimerComponent() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log('Effect: Setting up');
    const timer = setTimeout(() => {
      console.log('Timer fired');
    }, 1000);
    
    return () => {
      console.log('Cleanup: Clearing timer');
      clearTimeout(timer);
    };
  }, [count]); // count is a dependency
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
javascript
import { useState, useEffect } from 'react';

export function TimerComponent() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log('Effect: Setting up');
    const timer = setTimeout(() => {
      console.log('Timer fired');
    }, 1000);
    
    return () => {
      console.log('Cleanup: Clearing timer');
      clearTimeout(timer);
    };
  }, [count]); // count is a dependency
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

When you click the button, the console shows:

  1. Cleanup: Clearing timer (previous effect's cleanup)
  2. Effect: Setting up (new effect runs)

This prevents multiple timers from accumulating.

# Clearing Timers and Intervals

One of the most common cleanup scenarios is preventing multiple timers from piling up. Without cleanup, each render creates a new timer without canceling the previous one.

# setTimeout Cleanup

typescript
import { useState, useEffect } from 'react';

interface AlertProps {
  message: string;
}

export function Alert({ message }: AlertProps): React.ReactNode {
  const [isVisible, setIsVisible] = useState(true);
  
  useEffect(() => {
    // Set a timer to hide the alert after 3 seconds
    const timerId = setTimeout(() => {
      setIsVisible(false);
    }, 3000);
    
    // Cleanup: clear the timer if component unmounts before it fires
    return () => {
      clearTimeout(timerId);
    };
  }, []); // Empty dependency array—run once on mount
  
  if (!isVisible) return null;
  
  return (
    <div style={{ padding: '10px', backgroundColor: '#fff3cd' }}>
      {message}
    </div>
  );
}
javascript
import { useState, useEffect } from 'react';

export function Alert({ message }) {
  const [isVisible, setIsVisible] = useState(true);
  
  useEffect(() => {
    // Set a timer to hide the alert after 3 seconds
    const timerId = setTimeout(() => {
      setIsVisible(false);
    }, 3000);
    
    // Cleanup: clear the timer if component unmounts before it fires
    return () => {
      clearTimeout(timerId);
    };
  }, []); // Empty dependency array—run once on mount
  
  if (!isVisible) return null;
  
  return (
    <div style={{ padding: '10px', backgroundColor: '#fff3cd' }}>
      {message}
    </div>
  );
}

# setInterval Cleanup

Intervals are trickier because they keep firing indefinitely. Without cleanup, you'll end up with multiple intervals running simultaneously.

typescript
import { useState, useEffect } from 'react';

export function Stopwatch(): React.ReactNode {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  
  useEffect(() => {
    // Only set up interval if the stopwatch is running
    if (!isRunning) return;
    
    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
    
    // Cleanup: clear interval when component unmounts or dependencies change
    return () => {
      clearInterval(intervalId);
    };
  }, [isRunning]); // Re-run only when isRunning changes
  
  return (
    <div>
      <p>Time: {seconds}s</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Stop' : 'Start'}
      </button>
    </div>
  );
}
javascript
import { useState, useEffect } from 'react';

export function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  
  useEffect(() => {
    // Only set up interval if the stopwatch is running
    if (!isRunning) return;
    
    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
    
    // Cleanup: clear interval when component unmounts or dependencies change
    return () => {
      clearInterval(intervalId);
    };
  }, [isRunning]); // Re-run only when isRunning changes
  
  return (
    <div>
      <p>Time: {seconds}s</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Stop' : 'Start'}
      </button>
    </div>
  );
}

Notice the early return: if (!isRunning) return; prevents setting up an interval when it's not needed, and returning nothing (undefined) is perfectly valid when there's no cleanup to do.

# Removing Event Listeners

Event listeners accumulate silently. If you attach a listener without removing it, subsequent renders create more and more copies. Your click handler fires multiple times for a single click—not fun.

# Document Event Listeners

typescript
import { useEffect } from 'react';

interface ClickTrackerProps {
  onClickAnywhere: () => void;
}

export function ClickTracker({ onClickAnywhere }: ClickTrackerProps): React.ReactNode {
  useEffect(() => {
    // Define the handler function inside the effect
    const handleDocumentClick = () => {
      onClickAnywhere();
    };
    
    // Attach listener
    document.addEventListener('click', handleDocumentClick);
    
    // Cleanup: remove listener to prevent duplicates
    return () => {
      document.removeEventListener('click', handleDocumentClick);
    };
  }, [onClickAnywhere]); // Re-attach if callback changes
  
  return <div>Click anywhere on the page</div>;
}
javascript
import { useEffect } from 'react';

export function ClickTracker({ onClickAnywhere }) {
  useEffect(() => {
    // Define the handler function inside the effect
    const handleDocumentClick = () => {
      onClickAnywhere();
    };
    
    // Attach listener
    document.addEventListener('click', handleDocumentClick);
    
    // Cleanup: remove listener to prevent duplicates
    return () => {
      document.removeEventListener('click', handleDocumentClick);
    };
  }, [onClickAnywhere]); // Re-attach if callback changes
  
  return <div>Click anywhere on the page</div>;
}

Key detail: The handler function must be defined inside the effect (or be the same reference) so that you can remove it later. If you define it outside and pass it, you can still remove it because you're passing the same reference.

# Window Resize Listener

Here's a practical example that responds to window resizing:

typescript
import { useState, useEffect } from 'react';

export function ResponsiveContainer(): React.ReactNode {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };
    
    // Attach resize listener
    window.addEventListener('resize', handleResize);
    
    // Cleanup: remove listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return <div>Window width: {width}px</div>;
}
javascript
import { useState, useEffect } from 'react';

export function ResponsiveContainer() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };
    
    // Attach resize listener
    window.addEventListener('resize', handleResize);
    
    // Cleanup: remove listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return <div>Window width: {width}px</div>;
}

# Managing Subscriptions

Many libraries (Redux, Zustand, Observable streams, WebSocket connections) use subscriptions. Always unsubscribe in your cleanup function.

# Observable Subscription

typescript
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';

interface DataSubscriberProps {
  dataStream: Observable<string>;
}

export function DataSubscriber({ dataStream }: DataSubscriberProps): React.ReactNode {
  const [data, setData] = useState('');
  
  useEffect(() => {
    // Subscribe to the observable
    const subscription = dataStream.subscribe(
      (value) => setData(value),
      (error) => console.error('Stream error:', error)
    );
    
    // Cleanup: unsubscribe when component unmounts
    return () => {
      subscription.unsubscribe();
    };
  }, [dataStream]);
  
  return <div>Latest data: {data}</div>;
}
javascript
import { useEffect, useState } from 'react';

export function DataSubscriber({ dataStream }) {
  const [data, setData] = useState('');
  
  useEffect(() => {
    // Subscribe to the observable
    const subscription = dataStream.subscribe(
      (value) => setData(value),
      (error) => console.error('Stream error:', error)
    );
    
    // Cleanup: unsubscribe when component unmounts
    return () => {
      subscription.unsubscribe();
    };
  }, [dataStream]);
  
  return <div>Latest data: {data}</div>;
}

# WebSocket Connection

typescript
import { useEffect, useState } from 'react';

interface Message {
  type: string;
  payload: string;
}

export function WebSocketClient(): React.ReactNode {
  const [messages, setMessages] = useState<Message[]>([]);
  
  useEffect(() => {
    // Establish WebSocket connection
    const ws = new WebSocket('wss://echo.websocket.org');
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    // Cleanup: close connection when component unmounts
    return () => {
      ws.close();
    };
  }, []);
  
  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg.payload}</li>
      ))}
    </ul>
  );
}
javascript
import { useEffect, useState } from 'react';

export function WebSocketClient() {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    // Establish WebSocket connection
    const ws = new WebSocket('wss://echo.websocket.org');
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    // Cleanup: close connection when component unmounts
    return () => {
      ws.close();
    };
  }, []);
  
  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg.payload}</li>
      ))}
    </ul>
  );
}

# Aborting HTTP Requests

Modern browsers support the Fetch API with AbortController, allowing you to cancel in-flight requests when your component unmounts or dependencies change.

typescript
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
}

interface UserProfileProps {
  userId: number;
}

export function UserProfile({ userId }: UserProfileProps): React.ReactNode {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');
  
  useEffect(() => {
    // Create an abort controller for this request
    const controller = new AbortController();
    
    setLoading(true);
    setError('');
    
    // Fetch user data
    fetch(`/api/users/${userId}`, {
      signal: controller.signal, // Pass abort signal
    })
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
        setLoading(false);
      });
    
    // Cleanup: abort request if component unmounts or userId changes
    return () => {
      controller.abort();
    };
  }, [userId]); // Re-fetch when userId changes
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return <div>User: {user.name}</div>;
}
javascript
import { useEffect, useState } from 'react';

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');
  
  useEffect(() => {
    // Create an abort controller for this request
    const controller = new AbortController();
    
    setLoading(true);
    setError('');
    
    // Fetch user data
    fetch(`/api/users/${userId}`, {
      signal: controller.signal, // Pass abort signal
    })
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
        setLoading(false);
      });
    
    // Cleanup: abort request if component unmounts or userId changes
    return () => {
      controller.abort();
    };
  }, [userId]); // Re-fetch when userId changes
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return <div>User: {user.name}</div>;
}

This is production-ready code. When userId changes, the previous request is automatically aborted before fetching new data. When the component unmounts, any pending request is canceled.

# Real-World Scenarios

# Scenario: Analytics Event Tracking

Consider a common need in applications: tracking when users scroll to different sections of a page. You attach a scroll listener and want to clean it up properly.

typescript
import { useEffect } from 'react';

interface ScrollTrackingProps {
  onSectionChange: (section: string) => void;
}

export function ArticleWithTracking({ onSectionChange }: ScrollTrackingProps): React.ReactNode {
  useEffect(() => {
    let currentSection = 'intro';
    
    const handleScroll = () => {
      const sections = document.querySelectorAll('[data-section]');
      
      sections.forEach(section => {
        const rect = section.getBoundingClientRect();
        // Check if section is in viewport
        if (rect.top < window.innerHeight * 0.5 && rect.bottom > 0) {
          const newSection = section.getAttribute('data-section');
          if (newSection !== currentSection) {
            currentSection = newSection;
            onSectionChange(currentSection);
          }
        }
      });
    };
    
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [onSectionChange]);
  
  return (
    <>
      <div data-section="intro">Introduction...</div>
      <div data-section="main">Main content...</div>
      <div data-section="conclusion">Conclusion...</div>
    </>
  );
}
javascript
import { useEffect } from 'react';

export function ArticleWithTracking({ onSectionChange }) {
  useEffect(() => {
    let currentSection = 'intro';
    
    const handleScroll = () => {
      const sections = document.querySelectorAll('[data-section]');
      
      sections.forEach(section => {
        const rect = section.getBoundingClientRect();
        // Check if section is in viewport
        if (rect.top < window.innerHeight * 0.5 && rect.bottom > 0) {
          const newSection = section.getAttribute('data-section');
          if (newSection !== currentSection) {
            currentSection = newSection;
            onSectionChange(currentSection);
          }
        }
      });
    };
    
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [onSectionChange]);
  
  return (
    <>
      <div data-section="intro">Introduction...</div>
      <div data-section="main">Main content...</div>
      <div data-section="conclusion">Conclusion...</div>
    </>
  );
}

Performance note: This example tracks scroll events, which fire frequently. In production, you'd typically debounce or throttle the handler. The cleanup function ensures the listener is removed when the component unmounts.

# Common Mistakes

# Mistake 1: Forgetting to Clean Up Event Listeners

typescript
// ❌ WRONG: Listener accumulates with each render
useEffect(() => {
  const handler = () => console.log('clicked');
  document.addEventListener('click', handler);
  // Missing cleanup!
}, []);
typescript
// ✅ CORRECT: Listener is properly removed
useEffect(() => {
  const handler = () => console.log('clicked');
  document.addEventListener('click', handler);
  
  return () => {
    document.removeEventListener('click', handler);
  };
}, []);

# Mistake 2: Not Including Dependencies

typescript
// ❌ WRONG: Function dependency not in array causes stale closures
useEffect(() => {
  const handler = () => onCallback();
  document.addEventListener('click', handler);
  
  return () => {
    document.removeEventListener('click', handler);
  };
}, []); // Missing onCallback dependency!
typescript
// ✅ CORRECT: Include all dependencies
useEffect(() => {
  const handler = () => onCallback();
  document.addEventListener('click', handler);
  
  return () => {
    document.removeEventListener('click', handler);
  };
}, [onCallback]); // Dependency included

# Mistake 3: Using async in useEffect

typescript
// ❌ WRONG: useEffect can't be async
useEffect(async () => {
  const data = await fetch('/api/data');
}, []);
typescript
// ✅ CORRECT: Create async function inside effect
useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('/api/data');
  };
  
  fetchData();
}, []);

# Mistake 4: Not Returning the Cleanup Function

typescript
// ❌ WRONG: Cleanup logic doesn't get called
useEffect(() => {
  const handler = () => {};
  element.addEventListener('click', handler);
  
  element.removeEventListener('click', handler); // Runs immediately, not cleanup!
}, []);
typescript
// ✅ CORRECT: Return cleanup function
useEffect(() => {
  const handler = () => {};
  element.addEventListener('click', handler);
  
  return () => {
    element.removeEventListener('click', handler);
  };
}, []);

# FAQ

# Q: Do I need a cleanup function for every effect?

A: No. Only use cleanup functions when your effect creates resources that need to be released. Reading data, updating DOM text, or logging don't typically need cleanup. Effects that set up listeners, timers, subscriptions, or open connections always need cleanup.

# Q: What happens if I don't return a cleanup function?

A: React simply won't call anything. If you don't have cleanup logic, you don't need to return anything. Returning undefined (which happens when you don't return anything) is perfectly valid and common.

# Q: Can I call setState inside the cleanup function?

A: Technically yes, but it's usually a bad idea. If your component is unmounting, React will warn you about setting state on an unmounted component. If dependencies changed and a new effect is about to run, setting state might cause unexpected behavior. Keep cleanup functions focused on undoing the effect's work.

# Q: Why does my cleanup function run before the effect on every render?

A: Actually, it runs after the cleanup from the previous effect, before the new effect runs. React does this to prevent resource leaks. Old listeners/timers/subscriptions are removed before new ones are created.

# Q: If I have multiple useEffect hooks, do I need multiple cleanup functions?

A: Yes, each useEffect can have its own cleanup function. They're independent. If one effect sets a timer and another sets an event listener, each needs its own cleanup to remove what it created.

# Q: How do I cleanup when a component unmounts?

A: You don't do anything special. Your cleanup function runs automatically. Just return it from useEffect. When React unmounts the component, it automatically calls all cleanup functions.


Related Articles:

Questions? Leave your thoughts in the comments below. What cleanup scenarios have you encountered in your React projects? Share your experiences with cleanup functions—they help the community learn from real-world use cases!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT