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
- What is a Cleanup Function?
- How Cleanup Functions Execute
- Clearing Timers and Intervals
- Removing Event Listeners
- Managing Subscriptions
- Aborting HTTP Requests
- Real-World Scenarios
- Common Mistakes
- 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:
useEffect(() => {
// Your side effect code here
doSomething();
// Return a cleanup function
return () => {
// This runs before the effect reruns or when component unmounts
undoSomething();
};
}, [dependencies]);
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:
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>
);
}
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:
Cleanup: Clearing timer(previous effect's cleanup)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
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>
);
}
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.
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>
);
}
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
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>;
}
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:
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>;
}
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
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>;
}
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
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>
);
}
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.
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>;
}
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.
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>
</>
);
}
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
// ❌ WRONG: Listener accumulates with each render
useEffect(() => {
const handler = () => console.log('clicked');
document.addEventListener('click', handler);
// Missing cleanup!
}, []);
// ✅ 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
// ❌ 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!
// ✅ 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
// ❌ WRONG: useEffect can't be async
useEffect(async () => {
const data = await fetch('/api/data');
}, []);
// ✅ CORRECT: Create async function inside effect
useEffect(() => {
const fetchData = async () => {
const data = await fetch('/api/data');
};
fetchData();
}, []);
Mistake 4: Not Returning the Cleanup Function
// ❌ WRONG: Cleanup logic doesn't get called
useEffect(() => {
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler); // Runs immediately, not cleanup!
}, []);
// ✅ 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:
- useEffect Basics: Understanding React Hooks
- Memory Leaks in React: Detection and Prevention
- React Performance Optimization Strategies
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!
Google AdSense Placeholder
CONTENT SLOT