Proper Async Cleanup in useEffect
A common React anti-pattern is not properly handling cleanup for asynchronous operations in useEffect
, which can lead to memory leaks and state updates on unmounted components.
The Problem: Missing Cleanup for Async Operations
// ❌ Dangerous - no cleanup for async operation
useEffect(() => {
const fetchData = async () => {
const response = await fetch('/api/data');
setData(await response.json());
};
fetchData();
}, []);
Why This is Dangerous
- Memory Leaks: Async operations may complete after component unmounts
- State Updates on Unmounted Components: Can cause React warnings
- Race Conditions: Older requests might override newer ones
- Wasted Resources: Unnecessary network requests continue
Correct Implementation Patterns
1. Using a Cleanup Flag
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Request failed');
const data = await response.json();
if (isMounted) setData(data);
} catch (error) {
if (isMounted) setError(error.message);
}
};
fetchData();
return () => {
isMounted = false; // Cleanup function
};
}, []);
2. Using AbortController (Preferred for Fetch)
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(); // Aborts the fetch on unmount
};
}, []);
3. For Timers and Subscriptions
useEffect(() => {
const timerId = setTimeout(() => {
setValue('Timeout completed');
}, 1000);
const subscription = dataStream.subscribe(data => {
setData(data);
});
return () => {
clearTimeout(timerId); // Cleanup timer
subscription.unsubscribe(); // Cleanup subscription
};
}, []);
Best Practices
- Always Return a Cleanup Function: For any effect with async operations
- Use AbortController for Fetch: The modern way to cancel requests
- Check Mount Status: For operations that can’t be aborted
- Handle Errors Gracefully: Especially AbortError
- Use Linting Rules: Enforce cleanup with ESLint
Common Pitfalls
- Forgetting to Cancel Requests:
useEffect(() => {
fetch('/api/data').then(setData); // ❌ No cleanup
}, []);
- Ignoring AbortError:
try {
// fetch...
} catch (error) {
setError(error.message); // ❌ Includes AbortError
}
- Complex Async Functions:
useEffect(() => {
// ❌ Complex async logic without proper cleanup
async function process() {
const a = await step1();
const b = await step2(a);
setResult(b);
}
process();
}, []);
Advanced Patterns
1. Custom Hook with Cleanup
function useAsyncEffect(effect, dependencies) {
useEffect(() => {
let isMounted = true;
let cleanupFn = () => {};
const execute = async () => {
cleanupFn = effect() || (() => {});
};
execute();
return () => {
isMounted = false;
cleanupFn();
};
}, dependencies);
}
// Usage
useAsyncEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
2. Combining AbortController with Timeout
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
setError('Request timed out');
}, 5000);
const fetchData = async () => {
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
clearTimeout(timeoutId);
setData(await response.json());
} catch (error) {
if (error.name !== 'AbortError') {
setError(error.message);
}
}
};
fetchData();
return () => {
controller.abort();
clearTimeout(timeoutId);
};
}, []);
3. With React Query (Recommended Alternative)
import { useQuery } from 'react-query';
function MyComponent() {
const { data, error } = useQuery('data', () =>
fetch('/api/data').then(res => res.json())
);
// React Query handles cleanup automatically
}
Remember: Proper cleanup of async operations in useEffect
is crucial for:
- Preventing memory leaks
- Avoiding state updates on unmounted components
- Eliminating race conditions
- Optimizing performance
Always consider what needs to be cleaned up when your component unmounts or when dependencies change.