Understanding React useEffect: Common Pitfalls and Best Practices
The useEffect hook is one of React's most powerful features, but it's also the source of many bugs. Let's understand how it really works and avoid common mistakes.
How useEffect Works
useEffect runs after React renders your component. It's the place for side effects:
jsxuseEffect(() => { // This runs after every render console.log('Component rendered'); }); useEffect(() => { // This runs only on mount console.log('Component mounted'); }, []); useEffect(() => { // This runs when `count` changes console.log('Count changed:', count); }, [count]);
The Dependency Array
The dependency array determines when the effect runs.
Missing Dependencies (Bug!)
jsx// BUG: stale closure function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { fetchResults(query).then(setResults); }, []); // Missing `query` dependency! return <ResultsList results={results} />; } // FIX: Include all dependencies useEffect(() => { fetchResults(query).then(setResults); }, [query]);
Object and Array Dependencies
jsx// BUG: Infinite loop - new object every render function UserProfile({ userId }) { const options = { includeDetails: true }; useEffect(() => { fetchUser(userId, options); }, [userId, options]); // options changes every render! } // FIX 1: Memoize the object function UserProfile({ userId }) { const options = useMemo(() => ({ includeDetails: true }), []); useEffect(() => { fetchUser(userId, options); }, [userId, options]); } // FIX 2: Move inside useEffect function UserProfile({ userId }) { useEffect(() => { const options = { includeDetails: true }; fetchUser(userId, options); }, [userId]); } // FIX 3: Use primitive values function UserProfile({ userId, includeDetails }) { useEffect(() => { fetchUser(userId, { includeDetails }); }, [userId, includeDetails]); }
Cleanup Functions
Cleanup prevents memory leaks and stale data:
jsxuseEffect(() => { const subscription = dataSource.subscribe(handleData); return () => { subscription.unsubscribe(); // Cleanup on unmount or re-run }; }, [dataSource]);
Race Conditions with Async
jsx// BUG: Race condition useEffect(() => { async function fetchData() { const response = await fetch(`/api/user/${userId}`); const data = await response.json(); setUser(data); // Might set stale data! } fetchData(); }, [userId]); // FIX: Use cleanup flag useEffect(() => { let cancelled = false; async function fetchData() { const response = await fetch(`/api/user/${userId}`); const data = await response.json(); if (!cancelled) { setUser(data); } } fetchData(); return () => { cancelled = true; }; }, [userId]); // FIX 2: Use AbortController useEffect(() => { const controller = new AbortController(); async function fetchData() { try { const response = await fetch(`/api/user/${userId}`, { signal: controller.signal }); const data = await response.json(); setUser(data); } catch (err) { if (err.name !== 'AbortError') { setError(err); } } } fetchData(); return () => controller.abort(); }, [userId]);
Common Patterns
Data Fetching
jsxfunction useUser(userId) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); fetchUser(userId) .then(data => { if (!cancelled) { setUser(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [userId]); return { user, loading, error }; }
Event Listeners
jsxfunction useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { function handleResize() { setSize({ width: window.innerWidth, height: window.innerHeight }); } window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return size; }
Debounced Search
jsxfunction useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } function SearchComponent() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); useEffect(() => { if (debouncedQuery) { performSearch(debouncedQuery); } }, [debouncedQuery]); return <input value={query} onChange={e => setQuery(e.target.value)} />; }
When NOT to Use useEffect
Derived State
jsx// BAD: useEffect for derived state function ProductPage({ product }) { const [price, setPrice] = useState(0); useEffect(() => { setPrice(product.price * 1.1); // Adding tax }, [product.price]); // ... } // GOOD: Calculate during render function ProductPage({ product }) { const priceWithTax = product.price * 1.1; // ... }
Transforming Data
jsx// BAD: useEffect to transform function UserList({ users }) { const [sortedUsers, setSortedUsers] = useState([]); useEffect(() => { setSortedUsers([...users].sort((a, b) => a.name.localeCompare(b.name))); }, [users]); // ... } // GOOD: useMemo for expensive transforms function UserList({ users }) { const sortedUsers = useMemo( () => [...users].sort((a, b) => a.name.localeCompare(b.name)), [users] ); // ... }
Event Handlers
jsx// BAD: useEffect for event response function Form() { const [submitted, setSubmitted] = useState(false); useEffect(() => { if (submitted) { sendAnalytics('form_submitted'); } }, [submitted]); // ... } // GOOD: Handle in event handler function Form() { function handleSubmit() { sendAnalytics('form_submitted'); // ... } // ... }
Strict Mode Behavior
In development with Strict Mode, effects run twice to catch bugs:
jsxuseEffect(() => { console.log('Effect runs'); // Runs twice in dev! return () => console.log('Cleanup runs'); }, []);
This helps catch missing cleanups. If your effect breaks when run twice, fix it.
Conclusion
useEffect is powerful but requires understanding. Key takeaways:
- Include all dependencies in the array
- Always clean up subscriptions, timers, and async operations
- Don't use useEffect for derived state or event responses
- Use AbortController or flags to prevent race conditions
- Test with Strict Mode enabled
When in doubt, consider if you really need useEffect at all.