Back to Blog
ReactHooksuseEffectPatterns

React useEffect Patterns and Pitfalls

Master useEffect in React. From cleanup patterns to dependency management to common mistakes.

B
Bootspring Team
Engineering
September 1, 2021
7 min read

useEffect manages side effects in React components. Here's how to use it correctly.

Basic Patterns#

1import { useEffect, useState } from 'react'; 2 3// Run on every render 4useEffect(() => { 5 console.log('Runs after every render'); 6}); 7 8// Run once on mount 9useEffect(() => { 10 console.log('Runs once on mount'); 11}, []); 12 13// Run when dependencies change 14useEffect(() => { 15 console.log('Runs when count changes'); 16}, [count]); 17 18// Cleanup function 19useEffect(() => { 20 const subscription = subscribe(); 21 22 return () => { 23 subscription.unsubscribe(); // Cleanup 24 }; 25}, []);

Data Fetching#

1function UserProfile({ userId }: { userId: string }) { 2 const [user, setUser] = useState<User | null>(null); 3 const [loading, setLoading] = useState(true); 4 const [error, setError] = useState<Error | null>(null); 5 6 useEffect(() => { 7 let isCancelled = false; 8 9 async function fetchUser() { 10 try { 11 setLoading(true); 12 setError(null); 13 14 const response = await fetch(`/api/users/${userId}`); 15 const data = await response.json(); 16 17 if (!isCancelled) { 18 setUser(data); 19 } 20 } catch (err) { 21 if (!isCancelled) { 22 setError(err as Error); 23 } 24 } finally { 25 if (!isCancelled) { 26 setLoading(false); 27 } 28 } 29 } 30 31 fetchUser(); 32 33 return () => { 34 isCancelled = true; // Prevent state updates after unmount 35 }; 36 }, [userId]); 37 38 if (loading) return <Spinner />; 39 if (error) return <Error message={error.message} />; 40 if (!user) return null; 41 42 return <div>{user.name}</div>; 43} 44 45// With AbortController 46useEffect(() => { 47 const controller = new AbortController(); 48 49 async function fetchData() { 50 try { 51 const response = await fetch('/api/data', { 52 signal: controller.signal, 53 }); 54 const data = await response.json(); 55 setData(data); 56 } catch (err) { 57 if (err.name !== 'AbortError') { 58 setError(err); 59 } 60 } 61 } 62 63 fetchData(); 64 65 return () => controller.abort(); 66}, []);

Event Listeners#

1function useWindowSize() { 2 const [size, setSize] = useState({ 3 width: window.innerWidth, 4 height: window.innerHeight, 5 }); 6 7 useEffect(() => { 8 function handleResize() { 9 setSize({ 10 width: window.innerWidth, 11 height: window.innerHeight, 12 }); 13 } 14 15 window.addEventListener('resize', handleResize); 16 17 return () => { 18 window.removeEventListener('resize', handleResize); 19 }; 20 }, []); 21 22 return size; 23} 24 25// With debounce 26function useWindowSizeDebounced(delay = 100) { 27 const [size, setSize] = useState({ 28 width: window.innerWidth, 29 height: window.innerHeight, 30 }); 31 32 useEffect(() => { 33 let timeoutId: NodeJS.Timeout; 34 35 function handleResize() { 36 clearTimeout(timeoutId); 37 timeoutId = setTimeout(() => { 38 setSize({ 39 width: window.innerWidth, 40 height: window.innerHeight, 41 }); 42 }, delay); 43 } 44 45 window.addEventListener('resize', handleResize); 46 47 return () => { 48 window.removeEventListener('resize', handleResize); 49 clearTimeout(timeoutId); 50 }; 51 }, [delay]); 52 53 return size; 54} 55 56// Keyboard shortcuts 57function useKeyboardShortcut(key: string, callback: () => void) { 58 useEffect(() => { 59 function handleKeyDown(event: KeyboardEvent) { 60 if (event.key === key && (event.metaKey || event.ctrlKey)) { 61 event.preventDefault(); 62 callback(); 63 } 64 } 65 66 document.addEventListener('keydown', handleKeyDown); 67 return () => document.removeEventListener('keydown', handleKeyDown); 68 }, [key, callback]); 69}

Subscriptions#

1// WebSocket connection 2function useWebSocket(url: string) { 3 const [messages, setMessages] = useState<Message[]>([]); 4 const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); 5 6 useEffect(() => { 7 const ws = new WebSocket(url); 8 9 ws.onopen = () => setStatus('connected'); 10 ws.onclose = () => setStatus('disconnected'); 11 ws.onerror = () => setStatus('disconnected'); 12 13 ws.onmessage = (event) => { 14 const message = JSON.parse(event.data); 15 setMessages((prev) => [...prev, message]); 16 }; 17 18 return () => { 19 ws.close(); 20 }; 21 }, [url]); 22 23 return { messages, status }; 24} 25 26// Observable subscription 27function useObservable<T>(observable: Observable<T>, initialValue: T) { 28 const [value, setValue] = useState(initialValue); 29 30 useEffect(() => { 31 const subscription = observable.subscribe(setValue); 32 return () => subscription.unsubscribe(); 33 }, [observable]); 34 35 return value; 36}

Timers#

1// Interval 2function useInterval(callback: () => void, delay: number | null) { 3 const savedCallback = useRef(callback); 4 5 // Remember the latest callback 6 useEffect(() => { 7 savedCallback.current = callback; 8 }, [callback]); 9 10 // Set up the interval 11 useEffect(() => { 12 if (delay === null) return; 13 14 const id = setInterval(() => savedCallback.current(), delay); 15 return () => clearInterval(id); 16 }, [delay]); 17} 18 19// Timeout 20function useTimeout(callback: () => void, delay: number | null) { 21 const savedCallback = useRef(callback); 22 23 useEffect(() => { 24 savedCallback.current = callback; 25 }, [callback]); 26 27 useEffect(() => { 28 if (delay === null) return; 29 30 const id = setTimeout(() => savedCallback.current(), delay); 31 return () => clearTimeout(id); 32 }, [delay]); 33} 34 35// Countdown timer 36function useCountdown(initialSeconds: number) { 37 const [seconds, setSeconds] = useState(initialSeconds); 38 const [isRunning, setIsRunning] = useState(false); 39 40 useEffect(() => { 41 if (!isRunning || seconds <= 0) return; 42 43 const id = setInterval(() => { 44 setSeconds((s) => s - 1); 45 }, 1000); 46 47 return () => clearInterval(id); 48 }, [isRunning, seconds]); 49 50 return { 51 seconds, 52 isRunning, 53 start: () => setIsRunning(true), 54 pause: () => setIsRunning(false), 55 reset: () => setSeconds(initialSeconds), 56 }; 57}

Document Effects#

1// Update document title 2function useDocumentTitle(title: string) { 3 useEffect(() => { 4 const previousTitle = document.title; 5 document.title = title; 6 7 return () => { 8 document.title = previousTitle; 9 }; 10 }, [title]); 11} 12 13// Prevent scroll 14function usePreventScroll(prevent: boolean) { 15 useEffect(() => { 16 if (!prevent) return; 17 18 const originalStyle = window.getComputedStyle(document.body).overflow; 19 document.body.style.overflow = 'hidden'; 20 21 return () => { 22 document.body.style.overflow = originalStyle; 23 }; 24 }, [prevent]); 25} 26 27// Click outside 28function useClickOutside( 29 ref: RefObject<HTMLElement>, 30 handler: () => void 31) { 32 useEffect(() => { 33 function handleClick(event: MouseEvent) { 34 if (ref.current && !ref.current.contains(event.target as Node)) { 35 handler(); 36 } 37 } 38 39 document.addEventListener('mousedown', handleClick); 40 return () => document.removeEventListener('mousedown', handleClick); 41 }, [ref, handler]); 42}

Common Pitfalls#

1// ❌ Missing dependency 2function BadComponent({ userId }) { 3 const [user, setUser] = useState(null); 4 5 useEffect(() => { 6 fetchUser(userId).then(setUser); 7 }, []); // userId is missing! 8 9 return <div>{user?.name}</div>; 10} 11 12// ✅ Include all dependencies 13function GoodComponent({ userId }) { 14 const [user, setUser] = useState(null); 15 16 useEffect(() => { 17 fetchUser(userId).then(setUser); 18 }, [userId]); // userId included 19 20 return <div>{user?.name}</div>; 21} 22 23// ❌ Object/array in dependencies causes infinite loop 24function InfiniteLoop() { 25 const options = { page: 1 }; // New object every render! 26 27 useEffect(() => { 28 fetchData(options); 29 }, [options]); // Infinite loop! 30} 31 32// ✅ Memoize or use primitive values 33function Fixed() { 34 const page = 1; 35 36 useEffect(() => { 37 fetchData({ page }); 38 }, [page]); // Primitive value is stable 39} 40 41// Or with useMemo 42function FixedWithMemo() { 43 const options = useMemo(() => ({ page: 1 }), []); 44 45 useEffect(() => { 46 fetchData(options); 47 }, [options]); 48} 49 50// ❌ Function in dependencies 51function FunctionDependency({ onSave }) { 52 useEffect(() => { 53 const id = setInterval(onSave, 5000); 54 return () => clearInterval(id); 55 }, [onSave]); // If parent doesn't memoize onSave, this runs every render 56} 57 58// ✅ Use ref for latest callback 59function FixedFunction({ onSave }) { 60 const onSaveRef = useRef(onSave); 61 62 useEffect(() => { 63 onSaveRef.current = onSave; 64 }, [onSave]); 65 66 useEffect(() => { 67 const id = setInterval(() => onSaveRef.current(), 5000); 68 return () => clearInterval(id); 69 }, []); 70}

Sync vs Effects#

1// ❌ Using effect for derived state 2function Bad({ items }) { 3 const [total, setTotal] = useState(0); 4 5 useEffect(() => { 6 setTotal(items.reduce((sum, item) => sum + item.price, 0)); 7 }, [items]); 8 9 return <div>Total: {total}</div>; 10} 11 12// ✅ Compute during render 13function Good({ items }) { 14 const total = items.reduce((sum, item) => sum + item.price, 0); 15 16 return <div>Total: {total}</div>; 17} 18 19// ❌ Effect for event handler logic 20function Bad() { 21 const [submitted, setSubmitted] = useState(false); 22 23 useEffect(() => { 24 if (submitted) { 25 sendAnalytics('form_submitted'); 26 } 27 }, [submitted]); 28 29 return ( 30 <form onSubmit={() => setSubmitted(true)}> 31 ... 32 </form> 33 ); 34} 35 36// ✅ Put it in the event handler 37function Good() { 38 const handleSubmit = () => { 39 submitForm(); 40 sendAnalytics('form_submitted'); 41 }; 42 43 return ( 44 <form onSubmit={handleSubmit}> 45 ... 46 </form> 47 ); 48}

Testing Effects#

1import { renderHook, act, waitFor } from '@testing-library/react'; 2 3test('useCounter increments', () => { 4 const { result } = renderHook(() => useCounter(0)); 5 6 act(() => { 7 result.current.increment(); 8 }); 9 10 expect(result.current.count).toBe(1); 11}); 12 13test('useFetch loads data', async () => { 14 const { result } = renderHook(() => useFetch('/api/data')); 15 16 expect(result.current.loading).toBe(true); 17 18 await waitFor(() => { 19 expect(result.current.loading).toBe(false); 20 }); 21 22 expect(result.current.data).toBeDefined(); 23});

Best Practices#

Dependencies: ✓ Include all values used inside effect ✓ Use primitives when possible ✓ Memoize objects and functions ✓ Use refs for non-reactive values Cleanup: ✓ Always clean up subscriptions ✓ Cancel pending requests ✓ Clear timers ✓ Remove event listeners Structure: ✓ Keep effects focused ✓ Split unrelated logic ✓ Extract into custom hooks ✓ Avoid effects for derived state

Conclusion#

useEffect handles side effects like data fetching, subscriptions, and DOM manipulation. Always clean up effects, include all dependencies, and avoid common pitfalls like infinite loops. Extract reusable logic into custom hooks and compute derived state during render rather than in effects.

Share this article

Help spread the word about Bootspring