React useEffect: The Complete Guide
useEffect is both the most useful and most misused hook in React. It's also one of the most tested in interviews.
What useEffect Actually Does
useEffect schedules a function to run after React has painted the DOM. It's not a lifecycle hook — it's a synchronization mechanism: "keep this side effect in sync with these values."
useEffect(() => {
// This runs after every render where deps changed
subscribeToStream(userId)
return () => { // This runs before the next effect fires, and on unmount unsubscribeFromStream(userId) } }, [userId])
Mental model: "When userId changes, unsubscribe from old stream, subscribe to new one."
The Three Dependency Array Forms
// Form 1: No array — runs after EVERY render
useEffect(() => {
document.title = 'Count: ' + count
})
// Form 2: Empty array — runs ONCE after mount useEffect(() => { fetchInitialData() }, [])
// Form 3: With dependencies — runs when deps change useEffect(() => { fetchUser(userId) }, [userId])
The rule: Every reactive value (state, props, context) used inside the effect must be in the deps array. The ESLint rule exhaustive-deps enforces this automatically.
Cleanup: The Function You Should Always Return
The cleanup function prevents memory leaks and stale updates. Return it whenever your effect sets up something that needs teardown.
// Timer cleanup
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(id)
}, [])
// Event listener cleanup useEffect(() => { window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) }, [handleKey])
// Subscription cleanup useEffect(() => { const sub = store.subscribe(update => setState(update)) return () => sub.unsubscribe() }, [])
When cleanup runs: 1. Before the next effect fires (when deps changed) 2. When the component unmounts
Data Fetching Pattern (With AbortController)
useEffect(() => {
const controller = new AbortController()
let cancelled = false
async function loadUser() { try { const res = await fetch('/api/users/' + userId, { signal: controller.signal }) if (res.ok && !cancelled) { const data = await res.json() setUser(data) } } catch (err) { if (err.name !== 'AbortError') setError(err.message) } }
loadUser()
return () => { cancelled = true controller.abort() // cancels in-flight fetch when userId changes } }, [userId])
The AbortController ensures that if userId changes before the fetch completes, the stale response is ignored.
The 5 Most Common useEffect Bugs
Bug 1: Missing dependencies (stale closure)
// ❌ onMessage is captured at mount — never updates useEffect(() => { socket.on('message', onMessage) return () => socket.off('message', onMessage) }, []) // should include [onMessage]
// ✅ Wrap callback in useCallback or include in deps
Bug 2: Object/array as dependency (infinite loop)
// ❌ options = {} is new reference every render → infinite loop useEffect(() => { fetch('/api', options) }, [options])
// ✅ Either destructure to primitives or memoize the object useEffect(() => { fetch('/api', { page, size }) // primitives are stable }, [page, size])
Bug 3: Forgetting to handle async properly
// ❌ useEffect callback cannot be async directly useEffect(async () => { // returns a Promise, not cleanup fn! const data = await fetch('...') }, [])
// ✅ Define async function inside and call it useEffect(() => { async function load() { ... } load() }, [])
Bug 4: Setting state after unmount
// ❌ If component unmounts during fetch, setState throws warning useEffect(() => { fetch('/api/data').then(res => res.json()).then(data => setData(data)) }, [])
// ✅ Use cleanup flag or AbortController useEffect(() => { let alive = true fetch('/api/data').then(r => r.json()).then(d => { if (alive) setData(d) }) return () => { alive = false } }, [])
Bug 5: Effects that should be events Not everything belongs in useEffect. Avoid effects that react to user actions:
// ❌ Responding to a button click — this is an event, not an effect useEffect(() => { if (submitted) sendForm(formData) }, [submitted])
// ✅ Run side effects directly in event handlers function handleSubmit() { sendForm(formData) }
Lifecycle Method Equivalents
| Class lifecycle | Hook equivalent | |----------------|-----------------| | componentDidMount | useEffect(fn, []) | | componentDidUpdate | useEffect(fn, [deps]) | | componentWillUnmount | useEffect(() => { return cleanup }, []) | | getDerivedStateFromProps | setState + early return in render |
Practice useEffect questions at [JSPrep Pro](/auth).