L6: Cleanup & AbortController
Prevent race conditions and memory leaks with proper cleanup functions
What You'll Learn
Your app now loads data, shows loading states, and handles errors. But there's a subtle bug lurking! In this lesson, you'll:
- Understand race conditions and why they're dangerous
- Learn about cleanup functions in useEffect
- Implement AbortController to cancel requests
- Prevent memory leaks
- Write production-ready async code
The Hidden Problem: Race Conditions
What is a Race Condition?
A race condition occurs when multiple async operations compete, and the result depends on which one finishes first.
Scenario:
1. User visits HomePage → Fetch starts (takes 2 seconds)
2. User quickly navigates away → Component unmounts
3. Fetch completes → Tries to update unmounted component
4. Result: Memory leak + React warning!Real-World Example
function HomePage() {
const [listings, setListings] = useState([]);
useEffect(() => {
const fetchListings = async () => {
const data = await getAllListings(); // Takes 2 seconds
setListings(data); // ❌ May run after unmount!
};
fetchListings();
}, []);
return <ListingList listings={listings} />;
}What happens:
- Component mounts, fetch starts
- User navigates to different page
- HomePage unmounts
- 2 seconds later, fetch completes
setListings(data)tries to update non-existent component- React warning: "Can't perform a React state update on an unmounted component"
Warning: This is a memory leak! The component is gone but the callback still holds references.
Solution 1: Cleanup with Boolean Flag
The simplest solution uses a boolean flag:
Add Mounted Flag
useEffect(() => {
let isMounted = true;
const fetchListings = async () => {
setIsLoading(true);
setError(null);
try {
const data = await getAllListings();
// Only update if still mounted
if (isMounted) {
setListings(data);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchListings();
// Cleanup function
return () => {
isMounted = false;
};
}, []);How it works:
isMounted = true- Component is mounted- Async fetch starts
- If user navigates away, cleanup runs:
isMounted = false - Fetch completes, but
if (isMounted)prevents state updates
Solution 2: AbortController (Modern Approach)
The better solution is AbortController - it actually cancels the request!
Understanding AbortController
// Create controller
const controller = new AbortController();
// Get signal
const signal = controller.signal;
// Pass signal to fetch
fetch('/api/listings', { signal });
// Cancel the request
controller.abort(); // Request is cancelled!Benefits:
- ✅ Actually cancels the network request (saves bandwidth)
- ✅ Browser stops processing the response
- ✅ More efficient than boolean flag
- ✅ Standard web API (works everywhere)
Implementing AbortController
Update API to Support AbortController
First, modify your API functions to accept an abort signal:
export const getAllListings = async (options = {}) => {
try {
const response = await mockApiCall(mockListings, {
delayMs: 1500,
errorRate: 0.05,
signal: options.signal,
...options
});
return response.data;
} catch (error) {
// Check if error was due to abort
if (error.name === 'AbortError') {
console.log('Fetch was cancelled');
throw new Error('Request cancelled');
}
console.error('Error fetching listings:', error);
throw error;
}
};Update HomePage with AbortController
import { useState, useEffect } from 'react';
import { ListingList } from '@/components/ListingList';
import { ListingFilters } from '@/components/ListingFilters';
import { Spinner } from '@/components/Spinner';
import { ErrorMessage } from '@/components/ErrorMessage';
import { getAllListings } from '@/api';
export function HomePage() {
const [listings, setListings] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [dates, setDates] = useState({ checkIn: null, checkOut: null });
const [guests, setGuests] = useState(1);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchListings = async () => {
setIsLoading(true);
setError(null);
try {
const data = await getAllListings({ signal });
setListings(data);
console.log('Fetched listings:', data);
} catch (err) {
// Ignore abort errors
if (err.message !== 'Request cancelled') {
console.error('Failed to fetch listings:', err);
setError(err.message || 'Failed to load listings');
}
} finally {
setIsLoading(false);
}
};
fetchListings();
// Cleanup: abort the request if component unmounts
return () => {
controller.abort();
};
}, []); // Empty array = mount/unmount only
// Filter listings (same as before)
const filteredListings = listings.filter(listing => {
const matchesSearch =
search === '' ||
listing.title.toLowerCase().includes(search.toLowerCase()) ||
listing.location.toLowerCase().includes(search.toLowerCase());
const matchesGuests = listing.maxGuests >= guests;
const matchesDates = true;
return matchesSearch && matchesGuests && matchesDates;
});
// Error state
if (error) {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">
Find Your Perfect Stay
</h1>
<ErrorMessage
title="Unable to Load Listings"
message={error}
onRetry={() => window.location.reload()}
/>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">
Find Your Perfect Stay
</h1>
<Spinner
size="large"
message="Loading amazing stays..."
/>
</div>
</div>
);
}
// Main content
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Find Your Perfect Stay</h1>
<ListingFilters
search={search}
onSearchChange={setSearch}
dates={dates}
onDatesChange={setDates}
guests={guests}
onGuestsChange={setGuests}
/>
<div className="mt-8">
<p className="text-gray-600 mb-4">
Showing {filteredListings.length} of {listings.length} listings
</p>
<ListingList listings={filteredListings} />
</div>
</div>
</div>
);
}Key changes:
- Create
AbortControllerat start of effect - Pass
signalto API call - Return cleanup function that calls
controller.abort() - Ignore "Request cancelled" errors in catch block
Understanding Cleanup Functions
The Cleanup Pattern
useEffect(() => {
// 1. Setup code runs when component mounts (or deps change)
const subscription = subscribeToData();
const timer = setInterval(doSomething, 1000);
// 2. Cleanup function runs BEFORE next effect or unmount
return () => {
subscription.unsubscribe();
clearInterval(timer);
};
}, [dependencies]);When cleanup runs:
- Before the effect runs again (if dependencies changed)
- When the component unmounts
- NOT on the initial mount
Cleanup Examples
useEffect(() => {
// Subscribe to data source
const subscription = dataSource.subscribe(data => {
setData(data);
});
// Cleanup: unsubscribe
return () => {
subscription.unsubscribe();
};
}, []);Why: Prevents memory leaks from active subscriptions.
useEffect(() => {
// Set up interval
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
// Cleanup: clear interval
return () => {
clearInterval(interval);
};
}, []);Why: Prevents multiple timers running simultaneously.
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
// Add listener
window.addEventListener('resize', handleResize);
// Cleanup: remove listener
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);Why: Prevents duplicate listeners and memory leaks.
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch('/api/listings', {
signal: controller.signal
});
const data = await response.json();
setData(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
}
};
fetchData();
// Cleanup: abort fetch
return () => {
controller.abort();
};
}, []);Why: Cancels in-flight requests, prevents state updates on unmounted components.
Advanced Patterns
Testing Cleanup
Test Component Unmount
- Start on HomePage (fetch begins)
- Quickly navigate to another page
- Check console - should see "Fetch was cancelled"
- No React warnings about unmounted components
Test with React DevTools
Install React DevTools browser extension:
- Open DevTools → Components tab
- Find HomePage component
- Right-click → "Suspend this component"
- Watch cleanup function run
Add Debug Logging
useEffect(() => {
console.log('🟢 Effect setup');
const controller = new AbortController();
fetchData({ signal: controller.signal });
return () => {
console.log('🔴 Cleanup running');
controller.abort();
};
}, []);Expected console output:
🟢 Effect setup
[user navigates away]
🔴 Cleanup runningCommon Cleanup Mistakes
Key Takeaways
Cleanup prevents bugs!
What you learned:
- ✅ Race conditions happen when async operations complete after unmount
- ✅ Cleanup functions prevent memory leaks
- ✅ AbortController cancels network requests efficiently
- ✅ Return cleanup function from useEffect
- ✅ Cleanup runs before next effect and on unmount
Cleanup checklist:
- 🔲 Timers (setInterval, setTimeout) → clearInterval, clearTimeout
- 🔲 Subscriptions → unsubscribe
- 🔲 Event listeners → removeEventListener
- 🔲 Fetch requests → controller.abort()
- 🔲 WebSocket connections → socket.close()
Next: Extract reusable logic into custom hooks!
What's Next?
Your data fetching is now production-ready! But the code in HomePage is getting long. In the next lesson, you'll:
- 🎣 Create custom hooks to reuse logic
- 🧹 Clean up component code
- 📦 Separate concerns properly
- 🔧 Build a
useFetchListingshook
Let's refactor for maintainability! 🚀