Proper cleanup in useEffect prevents memory leaks and bugs. Here's how to do it right.
Basic Cleanup#
1import { useEffect, useState } from 'react';
2
3function Component() {
4 useEffect(() => {
5 // Setup
6 console.log('Effect runs');
7
8 // Cleanup function
9 return () => {
10 console.log('Cleanup runs');
11 };
12 }, []);
13
14 return <div>Component</div>;
15}
16
17// Cleanup runs when:
18// 1. Component unmounts
19// 2. Before effect re-runs (when dependencies change)Event Listeners#
1function WindowSize() {
2 const [size, setSize] = useState({
3 width: window.innerWidth,
4 height: window.innerHeight,
5 });
6
7 useEffect(() => {
8 const handleResize = () => {
9 setSize({
10 width: window.innerWidth,
11 height: window.innerHeight,
12 });
13 };
14
15 window.addEventListener('resize', handleResize);
16
17 // Cleanup: remove event listener
18 return () => {
19 window.removeEventListener('resize', handleResize);
20 };
21 }, []);
22
23 return <div>{size.width} x {size.height}</div>;
24}
25
26// Multiple events
27function KeyboardShortcuts({ onSave }: { onSave: () => void }) {
28 useEffect(() => {
29 const handleKeyDown = (e: KeyboardEvent) => {
30 if (e.ctrlKey && e.key === 's') {
31 e.preventDefault();
32 onSave();
33 }
34 };
35
36 document.addEventListener('keydown', handleKeyDown);
37
38 return () => {
39 document.removeEventListener('keydown', handleKeyDown);
40 };
41 }, [onSave]);
42
43 return null;
44}Timers and Intervals#
1// setTimeout cleanup
2function DelayedMessage({ delay }: { delay: number }) {
3 const [show, setShow] = useState(false);
4
5 useEffect(() => {
6 const timer = setTimeout(() => {
7 setShow(true);
8 }, delay);
9
10 return () => {
11 clearTimeout(timer);
12 };
13 }, [delay]);
14
15 return show ? <div>Hello!</div> : null;
16}
17
18// setInterval cleanup
19function Timer() {
20 const [seconds, setSeconds] = useState(0);
21
22 useEffect(() => {
23 const interval = setInterval(() => {
24 setSeconds(s => s + 1);
25 }, 1000);
26
27 return () => {
28 clearInterval(interval);
29 };
30 }, []);
31
32 return <div>{seconds} seconds</div>;
33}
34
35// Debounced effect
36function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
37 const [query, setQuery] = useState('');
38
39 useEffect(() => {
40 const timer = setTimeout(() => {
41 if (query) {
42 onSearch(query);
43 }
44 }, 300);
45
46 return () => {
47 clearTimeout(timer);
48 };
49 }, [query, onSearch]);
50
51 return (
52 <input
53 value={query}
54 onChange={(e) => setQuery(e.target.value)}
55 />
56 );
57}Subscriptions#
1// WebSocket cleanup
2function ChatRoom({ roomId }: { roomId: string }) {
3 const [messages, setMessages] = useState<Message[]>([]);
4
5 useEffect(() => {
6 const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
7
8 socket.onmessage = (event) => {
9 const message = JSON.parse(event.data);
10 setMessages(prev => [...prev, message]);
11 };
12
13 socket.onopen = () => {
14 console.log('Connected to room:', roomId);
15 };
16
17 return () => {
18 socket.close();
19 };
20 }, [roomId]);
21
22 return (
23 <ul>
24 {messages.map(msg => (
25 <li key={msg.id}>{msg.text}</li>
26 ))}
27 </ul>
28 );
29}
30
31// EventEmitter subscription
32function NotificationListener() {
33 const [notifications, setNotifications] = useState<Notification[]>([]);
34
35 useEffect(() => {
36 const handler = (notification: Notification) => {
37 setNotifications(prev => [...prev, notification]);
38 };
39
40 eventEmitter.on('notification', handler);
41
42 return () => {
43 eventEmitter.off('notification', handler);
44 };
45 }, []);
46
47 return <NotificationList items={notifications} />;
48}
49
50// Redux store subscription
51function useSelector<T>(selector: (state: RootState) => T): T {
52 const store = useContext(StoreContext);
53 const [value, setValue] = useState(() => selector(store.getState()));
54
55 useEffect(() => {
56 const unsubscribe = store.subscribe(() => {
57 setValue(selector(store.getState()));
58 });
59
60 return unsubscribe;
61 }, [store, selector]);
62
63 return value;
64}Fetch Requests#
1// Abort controller for fetch
2function UserProfile({ userId }: { userId: string }) {
3 const [user, setUser] = useState<User | null>(null);
4 const [loading, setLoading] = useState(true);
5
6 useEffect(() => {
7 const abortController = new AbortController();
8
9 async function fetchUser() {
10 try {
11 setLoading(true);
12 const response = await fetch(`/api/users/${userId}`, {
13 signal: abortController.signal,
14 });
15 const data = await response.json();
16 setUser(data);
17 } catch (error) {
18 if (error.name !== 'AbortError') {
19 console.error('Fetch error:', error);
20 }
21 } finally {
22 setLoading(false);
23 }
24 }
25
26 fetchUser();
27
28 return () => {
29 abortController.abort();
30 };
31 }, [userId]);
32
33 if (loading) return <Spinner />;
34 return <div>{user?.name}</div>;
35}
36
37// Boolean flag pattern (simpler but less clean)
38function UserProfileSimple({ userId }: { userId: string }) {
39 const [user, setUser] = useState<User | null>(null);
40
41 useEffect(() => {
42 let cancelled = false;
43
44 async function fetchUser() {
45 const response = await fetch(`/api/users/${userId}`);
46 const data = await response.json();
47
48 if (!cancelled) {
49 setUser(data);
50 }
51 }
52
53 fetchUser();
54
55 return () => {
56 cancelled = true;
57 };
58 }, [userId]);
59
60 return <div>{user?.name}</div>;
61}Animation Frames#
1function AnimatedCounter({ target }: { target: number }) {
2 const [count, setCount] = useState(0);
3
4 useEffect(() => {
5 let animationId: number;
6 let start: number;
7 const duration = 1000;
8
9 const animate = (timestamp: number) => {
10 if (!start) start = timestamp;
11 const progress = Math.min((timestamp - start) / duration, 1);
12
13 setCount(Math.floor(progress * target));
14
15 if (progress < 1) {
16 animationId = requestAnimationFrame(animate);
17 }
18 };
19
20 animationId = requestAnimationFrame(animate);
21
22 return () => {
23 cancelAnimationFrame(animationId);
24 };
25 }, [target]);
26
27 return <span>{count}</span>;
28}
29
30// Smooth scroll position
31function useScrollPosition() {
32 const [scrollY, setScrollY] = useState(0);
33
34 useEffect(() => {
35 let animationId: number;
36
37 const updatePosition = () => {
38 setScrollY(window.scrollY);
39 animationId = requestAnimationFrame(updatePosition);
40 };
41
42 animationId = requestAnimationFrame(updatePosition);
43
44 return () => {
45 cancelAnimationFrame(animationId);
46 };
47 }, []);
48
49 return scrollY;
50}Observers#
1// IntersectionObserver
2function LazyImage({ src, alt }: { src: string; alt: string }) {
3 const [isVisible, setIsVisible] = useState(false);
4 const ref = useRef<HTMLDivElement>(null);
5
6 useEffect(() => {
7 const observer = new IntersectionObserver(
8 ([entry]) => {
9 if (entry.isIntersecting) {
10 setIsVisible(true);
11 observer.disconnect();
12 }
13 },
14 { threshold: 0.1 }
15 );
16
17 if (ref.current) {
18 observer.observe(ref.current);
19 }
20
21 return () => {
22 observer.disconnect();
23 };
24 }, []);
25
26 return (
27 <div ref={ref}>
28 {isVisible && <img src={src} alt={alt} />}
29 </div>
30 );
31}
32
33// ResizeObserver
34function useElementSize<T extends HTMLElement>() {
35 const ref = useRef<T>(null);
36 const [size, setSize] = useState({ width: 0, height: 0 });
37
38 useEffect(() => {
39 const element = ref.current;
40 if (!element) return;
41
42 const observer = new ResizeObserver(([entry]) => {
43 setSize({
44 width: entry.contentRect.width,
45 height: entry.contentRect.height,
46 });
47 });
48
49 observer.observe(element);
50
51 return () => {
52 observer.disconnect();
53 };
54 }, []);
55
56 return { ref, ...size };
57}
58
59// MutationObserver
60function useMutationObserver(
61 ref: RefObject<HTMLElement>,
62 callback: MutationCallback,
63 options: MutationObserverInit
64) {
65 useEffect(() => {
66 const element = ref.current;
67 if (!element) return;
68
69 const observer = new MutationObserver(callback);
70 observer.observe(element, options);
71
72 return () => {
73 observer.disconnect();
74 };
75 }, [ref, callback, options]);
76}Third-Party Libraries#
1// Chart.js cleanup
2function ChartComponent({ data }: { data: ChartData }) {
3 const canvasRef = useRef<HTMLCanvasElement>(null);
4 const chartRef = useRef<Chart | null>(null);
5
6 useEffect(() => {
7 if (!canvasRef.current) return;
8
9 chartRef.current = new Chart(canvasRef.current, {
10 type: 'line',
11 data,
12 });
13
14 return () => {
15 chartRef.current?.destroy();
16 };
17 }, [data]);
18
19 return <canvas ref={canvasRef} />;
20}
21
22// Map library cleanup
23function MapComponent({ center }: { center: LatLng }) {
24 const containerRef = useRef<HTMLDivElement>(null);
25
26 useEffect(() => {
27 if (!containerRef.current) return;
28
29 const map = new Map(containerRef.current, {
30 center,
31 zoom: 10,
32 });
33
34 return () => {
35 map.remove();
36 };
37 }, [center]);
38
39 return <div ref={containerRef} style={{ height: 400 }} />;
40}Common Mistakes#
1// BAD: Missing cleanup
2function BadComponent() {
3 useEffect(() => {
4 window.addEventListener('resize', handleResize);
5 // No cleanup! Memory leak!
6 }, []);
7}
8
9// BAD: Cleanup in wrong place
10function BadCleanup() {
11 useEffect(() => {
12 const timer = setTimeout(doSomething, 1000);
13 }, []);
14
15 // This doesn't work - cleanup must be returned from effect
16 useEffect(() => {
17 return () => clearTimeout(timer); // timer is not in scope!
18 }, []);
19}
20
21// GOOD: Proper cleanup
22function GoodComponent() {
23 useEffect(() => {
24 const handleResize = () => { /* ... */ };
25 window.addEventListener('resize', handleResize);
26
27 return () => {
28 window.removeEventListener('resize', handleResize);
29 };
30 }, []);
31}Best Practices#
Always Clean Up:
✓ Event listeners
✓ Timers and intervals
✓ Subscriptions
✓ Fetch requests
✓ WebSocket connections
✓ Observers
Patterns:
✓ Return cleanup function from effect
✓ Use AbortController for fetch
✓ Store references for later cleanup
✓ Check for mounted state
Performance:
✓ Clean up before re-running effects
✓ Use refs for mutable values
✓ Debounce frequent updates
✓ Disconnect observers when done
Avoid:
✗ Forgetting cleanup functions
✗ Setting state after unmount
✗ Memory leaks from listeners
✗ Stale closures in callbacks
Conclusion#
Cleanup functions in useEffect prevent memory leaks and unexpected behavior. Always clean up event listeners, timers, subscriptions, and network requests. Use AbortController for fetch requests and store references to clean up third-party library instances.