Back to Blog
ReactHooksuseEffectCleanup

React useEffect Cleanup Patterns

Master React useEffect cleanup. From subscriptions to timers to preventing memory leaks.

B
Bootspring Team
Engineering
June 22, 2020
7 min read

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.

Share this article

Help spread the word about Bootspring