Deep Dive9 min read · Updated 2025-06-01

React useEffect: The Complete Guide (Deps, Cleanup & Common Bugs)

Master useEffect once and for all. Understand dependency arrays, cleanup functions, data fetching patterns, and the 5 bugs that appear in every React interview.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

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).

📚 Practice These Topics
UseEffect
4–8 questions

Put This Into Practice

Reading articles is passive. JSPrep Pro makes you actively recall, predict output, and get AI feedback.

Start Free →Browse All Questions

Related Articles

Deep Dive
We Built a RAG-Powered AI Question Engine Into a JavaScript Interview Platform — Here's Exactly How It Works
12 min read
Build Systems
Monorepo with Turborepo vs Nx: The Complete Comparison (2025)
9 min read
Core Concepts
map() vs forEach() in JavaScript: Which One to Use and Why It Matters
7 min read