Proper Usage of Async Functions in useEffect
A common React mistake is improperly handling async functions inside useEffect
, which can lead to memory leaks, race conditions, and unexpected behavior. Here’s how to correctly implement async operations in your effects.
The Problem: Incorrect Patterns
// ❌ Bad - direct async function
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// ❌ Bad - missing cleanup
useEffect(() => {
fetchData().then(setData);
}, []);
// ❌ Bad - potential race condition
useEffect(() => {
const loadData = async () => {
const data = await fetchData(id);
setData(data);
};
loadData();
}, [id]);
Correct Solutions
1. Basic Async Effect
// ✅ Correct - proper async in useEffect
useEffect(() => {
let isMounted = true; // Cleanup flag
const fetchData = async () => {
try {
const data = await fetch('/api/data');
if (isMounted) setData(data);
} catch (error) {
if (isMounted) setError(error);
}
};
fetchData();
return () => {
isMounted = false; // Cleanup function
};
}, []); // Empty array = runs once
2. With Dependency Tracking
// ✅ Correct - handles dependency changes
useEffect(() => {
let isMounted = true;
const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`/api/data/${id}`, {
signal: abortController.signal
});
const data = await response.json();
if (isMounted) setData(data);
} catch (error) {
if (error.name !== 'AbortError' && isMounted) {
setError(error.message);
}
}
};
fetchData();
return () => {
isMounted = false;
abortController.abort(); // Cancel pending request
};
}, [id]); // Re-runs when id changes
3. Using Async/Await with Cleanup
// ✅ Correct - async/await with proper cleanup
useEffect(() => {
const abortController = new AbortController();
(async () => {
try {
const response = await axios.get('/api/data', {
signal: abortController.signal
});
setData(response.data);
} catch (error) {
if (!axios.isCancel(error)) {
setError(error.message);
}
}
})();
return () => abortController.abort();
}, []);
Advanced Patterns
1. Custom Hook for Async Effects
// ✅ Reusable async effect hook
function useAsyncEffect(asyncFn, onSuccess, onError, deps) {
useEffect(() => {
let isMounted = true;
const abortController = new AbortController();
asyncFn(abortController)
.then(data => isMounted && onSuccess?.(data))
.catch(error => isMounted && onError?.(error));
return () => {
isMounted = false;
abortController.abort();
};
}, deps);
}
// Usage
useAsyncEffect(
async (abortController) => {
const response = await fetch('/api/data', {
signal: abortController.signal
});
return await response.json();
},
setData,
setError,
[id]
);
2. Race Condition Prevention
// ✅ Prevents race conditions
useEffect(() => {
let isCurrent = true;
const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`/api/data/${id}`, {
signal: abortController.signal
});
const data = await response.json();
if (isCurrent) setData(data);
} catch (error) {
if (isCurrent && error.name !== 'AbortError') {
setError(error.message);
}
}
};
fetchData();
return () => {
isCurrent = false;
abortController.abort();
};
}, [id]);
Best Practices
- Always include cleanup – Cancel pending requests on unmount
- Handle errors properly – Catch and manage all error cases
- Prevent race conditions – Track “current” request with flags
- Use AbortController – Modern way to cancel fetch requests
- Consider dependencies carefully – Ensure effect re-runs when needed
- Avoid direct async functions – useEffect callback shouldn’t be async
Common Mistakes to Avoid
- No cleanup leading to memory leaks and state updates on unmounted components
- Ignoring errors resulting in silent failures
- Race conditions when dependencies change quickly
- Over-fetching by not properly tracking dependencies
- Using .then/.catch without cleanup logic
Remember that proper async handling in useEffect
is crucial for building reliable React applications. These patterns ensure your components handle data fetching safely and efficiently.