Skip to main content

useEffect Cleanup Function: Prevent Memory Leaks

The useEffect cleanup function is a function you return from a useEffect Hook to tear down side effects before a component unmounts or before the effect runs again with new dependencies. It prevents memory leaks by stopping timers, removing event listeners, and canceling subscriptions. Without cleanup, side effects continue running in the background even after a component is removed, consuming memory and potentially causing bugs.

Key Takeaways

  • Return a function from useEffect to schedule cleanup: useEffect(() => { /* setup */ return () => { /* cleanup */ }; }, [deps]).
  • Cleanup runs on unmount: When a component is removed from the DOM, React executes the cleanup function to tear down side effects.
  • Cleanup runs before re-running the effect: If dependencies change, cleanup runs before the effect runs again with new dependency values.
  • Always clean up subscriptions, timers, and event listeners: Forgetting cleanup is a common source of memory leaks and bugs.
  • Use the exact same function reference: When removing an event listener, pass the same function that was added.

The Problem: Memory Leaks in React

Imagine a component that sets up a timer using setInterval. If the user navigates away and the component unmounts, what happens to the timer? Without cleanup, it continues running in the browser's memory—potentially trying to update state on a component that no longer exists. This is a memory leak.

Consider this broken example:

function TickingClock() {
const [time, setTime] = useState(new Date());

useEffect(() => {
// This interval NEVER stops, even after component unmounts!
setInterval(() => {
setTime(new Date());
}, 1000);
}, []);

return <div>{time.toLocaleTimeString()}</div>;
}

If you mount and unmount this component 10 times, you'll have 10 intervals running in the background, wasting memory and CPU cycles. The solution is a cleanup function.

The Cleanup Function Syntax

To create a cleanup function, simply return a function from your useEffect callback:

useEffect(() => {
// Your side effect logic goes here.
console.log('Effect has run.');

// Return a function to be the cleanup.
return () => {
console.log('Cleanup is running!');
};
}, []);

React holds onto the returned function and executes it at the appropriate time. The cleanup function can have any code you need—clearing intervals, removing listeners, canceling requests, etc.

Cleaning Up a Timer: Complete Example

Here's the ticking clock component done correctly with cleanup:

// TickingClock.jsx

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

function TickingClock() {
const [time, setTime] = useState(new Date());

useEffect(() => {
// Set up the side effect.
console.log('Setting up the interval...');
const intervalId = setInterval(() => {
setTime(new Date());
}, 1000);

// Return a cleanup function to stop the interval.
return () => {
console.log('Cleaning up the interval!');
clearInterval(intervalId);
};
}, []); // Empty array = runs once on mount, cleanup on unmount.

return <div>{time.toLocaleTimeString()}</div>;
}

// Parent component to demonstrate mounting/unmounting
export default function ClockContainer() {
const [showClock, setShowClock] = useState(true);

return (
<div>
<button onClick={() => setShowClock(!showClock)}>
{showClock ? 'Hide Clock' : 'Show Clock'}
</button>
{showClock && <TickingClock />}
</div>
);
}

What Happens:

  1. When TickingClock mounts, useEffect runs and sets up an interval that updates time every second.
  2. When you click "Hide Clock", the component unmounts.
  3. Just before unmounting, React executes the cleanup function, which calls clearInterval(intervalId).
  4. The timer is stopped. No more memory leak.

Cleaning Up Event Listeners: Complete Example

Event listeners attached to global objects like window or document must also be cleaned up:

// MouseTracker.jsx

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

function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });

useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};

// Set up the side effect: add the event listener.
console.log('Adding event listener...');
window.addEventListener('mousemove', handleMouseMove);

// Return a cleanup function to remove the listener.
return () => {
console.log('Removing event listener!');
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);

return (
<p>
Mouse position: ({position.x}, {position.y})
</p>
);
}

export default MouseTracker;

Key Point: You must pass the exact same function reference to both addEventListener and removeEventListener. Because handleMouseMove is defined inside the effect, it's available to the cleanup function via closure.

When Does the Cleanup Function Run?

The cleanup function runs in two scenarios:

1. On Component Unmount

When a component is removed from the UI, React executes the cleanup function to tear down the effect.

2. Before Re-running the Effect (If Dependencies Changed)

If your useEffect has dependencies, the cleanup function runs before the effect runs again with new dependency values. This prevents multiple instances of the same effect from running simultaneously.

Example with Dependency:

function UserProfile({ userId }) {
useEffect(() => {
console.log(`Subscribing to user ${userId}`);

return () => {
console.log(`Unsubscribing from user ${userId}`);
};
}, [userId]); // Effect depends on userId
}

If userId changes from 1 to 2, the console output is:

Unsubscribing from user 1     // Cleanup runs first
Subscribing to user 2 // Then the new effect

This ensures you don't subscribe twice or leave stale subscriptions active.

Common Cleanup Scenarios

  • Clear timers: clearInterval(id), clearTimeout(id)
  • Remove event listeners: window.removeEventListener(event, handler)
  • Abort fetch requests: abortController.abort()
  • Cancel subscriptions: Call an unsubscribe function
  • Close WebSocket connections: socket.close()
  • Unsubscribe from observables: Call the unsubscribe method

Frequently Asked Questions

What if I don't return anything from useEffect?

If you don't return a cleanup function, no cleanup runs. This is fine if your effect doesn't set up any long-running side effects. For timers, listeners, and subscriptions, always clean up.

Can I have multiple cleanup functions in one component?

No, each useEffect returns one cleanup function. If you need multiple cleanups, use multiple useEffect calls:

useEffect(() => {
// Setup for effect 1
return () => { /* cleanup 1 */ };
}, [dep1]);

useEffect(() => {
// Setup for effect 2
return () => { /* cleanup 2 */ };
}, [dep2]);

What happens if I return a cleanup function with an empty dependency array?

With an empty dependency array [], the effect runs once on mount, and the cleanup runs once on unmount. This is the most common pattern for one-time setup and teardown (like starting a timer or adding a global listener).

Does the cleanup function have access to state or props?

Yes, via closure. The cleanup function can access the state and props values from when the effect ran. However, be careful—if you reference a value in the cleanup, it should also be in the dependency array to ensure consistency.

Is it possible to cause an error in the cleanup function?

Yes, but it shouldn't crash your app. React catches errors in cleanup functions and logs them. Always wrap risky cleanup code in try-catch if you're concerned about errors.

Conclusion

The useEffect cleanup function is essential for building robust React applications. By cleaning up timers, event listeners, and subscriptions, you prevent memory leaks, reduce bugs, and ensure your app runs smoothly. Understanding when and how cleanup runs—on unmount and before re-running—is a mark of a professional React developer.

Further Reading