![]()
Proper Async Cleanup in useEffect
A common React mistake is not properly handling cleanup for asynchronous operations in useEffect, which can lead to:
- Memory leaks
- State updates on unmounted components
- Race conditions
- Unexpected application behavior
The Problem (Improper Async Cleanup)
// ❌ Wrong - No cleanup for async operation
useEffect(() => {
const fetchData = async () => {
const response = await fetch('/api/data');
const data = await response.json();
setData(data); // ❌ May try to update unmounted component
};
fetchData();
}, []);
Correct Solutions
1. Using AbortController (Recommended)
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error.message);
}
}
};
fetchData();
return () => controller.abort(); // ✅ Cleanup function
}, []);
2. Mounted Check Pattern
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch('/api/data');
const data = await response.json();
if (isMounted) setData(data); // ✅ Only update if mounted
} catch (error) {
if (isMounted) setError(error.message);
}
};
fetchData();
return () => {
isMounted = false; // ✅ Cleanup function
};
}, []);
3. For Timers/Intervals
useEffect(() => {
const timer = setInterval(() => {
console.log('Timer tick');
}, 1000);
return () => clearInterval(timer); // ✅ Cleanup interval
}, []);
Common Async Operations Needing Cleanup
- Fetch requests (using AbortController)
- Timers (setTimeout, setInterval)
- WebSocket connections
- Observables/subscriptions
- Async event listeners
Best Practices
- Always return a cleanup function from useEffect
- Use AbortController for fetch requests
- Cancel pending operations when unmounting
- Handle abort errors appropriately
- Avoid state updates after unmount
- Test cleanup behavior with fast component unmounts
Advanced Patterns
1. Custom Hook with Cleanup
function useAsyncEffect(effect, deps) {
useEffect(() => {
let isMounted = true;
let cleanupFn = () => {};
const execute = async () => {
cleanupFn = effect() || (() => {});
};
execute();
return () => {
isMounted = false;
cleanupFn();
};
}, deps);
}
// Usage
useAsyncEffect(async () => {
const data = await fetchData();
setData(data);
return () => { /* optional cleanup */ };
}, []);
2. Combining AbortController with Mount Check
useEffect(() => {
const controller = new AbortController();
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const data = await response.json();
if (isMounted) setData(data);
} catch (error) {
if (isMounted && error.name !== 'AbortError') {
setError(error.message);
}
}
};
fetchData();
return () => {
isMounted = false;
controller.abort();
};
}, []);
3. Cleanup for Multiple Operations
useEffect(() => {
const controller1 = new AbortController();
const controller2 = new AbortController();
const timer = setInterval(() => {}, 1000);
const fetchData = async () => {
// Multiple async operations
};
fetchData();
return () => {
controller1.abort();
controller2.abort();
clearInterval(timer);
};
}, []);
Remember: Proper async cleanup is essential for:
- Preventing memory leaks
- Avoiding state updates on unmounted components
- Maintaining application stability
- Ensuring predictable behavior
Always consider what needs to be cleaned up when your effect runs async operations. The cleanup function should cancel or ignore any pending operations that are no longer needed when the component unmounts or when dependencies change.
