Beginner0 questionsFull Guide

useEffect Hook — Complete React Interview Guide

Master useEffect: dependency array, cleanup, stale closures, data fetching, race conditions, and every pattern asked in React interviews.

The Mental Model

Picture useEffect as a contract you make with React: "After you finish rendering and painting the screen, run this side effect for me." React honours that contract after every render unless you tell it to be more selective. The dependency array is your selectivity filter. No array means React runs the effect after every single render — like leaving the tap fully open. An empty array means run once, after the first render only — the tap opens once and stays shut. An array with values means re-run whenever those values change — the tap opens and closes in sync with those specific variables. The cleanup function is the other half of the contract. When the effect runs again — or when the component unmounts — React calls your cleanup first, before running the new effect. Think of it like a hotel room: the cleaner (cleanup) comes in before the next guest (new effect) arrives. Skipping cleanup causes the old guest and new guest to share the same room: doubled event listeners, zombie timers, stale subscriptions. The critical mental shift most developers need: useEffect is not a lifecycle method. It is a synchronisation tool. Its job is to keep an external system (DOM, API, timer, subscription) in sync with React state. When you think "run on mount" you're thinking in lifecycle terms. Think instead: "what external thing needs to stay in sync with these values?" That question will always guide you to the right dependency array.

The Explanation

What useEffect actually does — the execution model

React renders in two phases:

  1. Render phase — React calls your component function, builds a virtual DOM tree, diffs it against the previous one. This must be pure and side-effect-free.
  2. Commit phase — React writes changes to the real DOM. After this, effects run.

useEffect runs after the commit phase, asynchronously. The browser has already painted the updated UI before your effect starts. This is intentional — effects should not block the visual update. If you need to run something synchronously before the browser paints (e.g., measuring DOM layout), use useLayoutEffect instead.

// Execution order for a component that renders twice:
// 1. Component renders (render phase)
// 2. React commits to DOM
// 3. Browser paints
// 4. useEffect runs (effect 1)
// --- state update → re-render ---
// 5. Component renders again
// 6. React commits to DOM
// 7. Browser paints
// 8. React runs cleanup from effect 1
// 9. useEffect runs (effect 2)

The three signatures and what each means

// Signature 1: No dependency array — runs after EVERY render
useEffect(() => {
  document.title = `Score: ${score}`
})
// Use when: the effect must always stay in sync with every state change.
// Rarely what you want — causes unnecessary work.
 
// Signature 2: Empty dependency array — runs ONCE after mount
useEffect(() => {
  fetchUserData()
  return () => { /* cleanup on unmount */ }
}, [])
// Use when: set up that only needs to happen once (subscriptions, one-time fetches, analytics).
 
// Signature 3: Dependency array with values — runs when values change
useEffect(() => {
  fetchPosts(userId)
}, [userId])
// Use when: an external system needs to sync with specific state/props.

The dependency array — the most misunderstood part

The dependency array is not an optimisation — it is a declaration of what the effect depends on. You are telling React: "this effect uses these values." React uses the array to decide when the effect is stale and needs to re-run.

// WRONG — lying to React about dependencies
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1)  // uses count
  }, 1000)
  return () => clearInterval(interval)
}, [])  // ← [] says "no dependencies" — but we use count!
// Bug: count is captured at 0 and never updates — stale closure
 
// RIGHT — use the functional update form to avoid the dependency
useEffect(() => {
  const interval = setInterval(() => {
    setCount(prev => prev + 1)  // does not read count from scope
  }, 1000)
  return () => clearInterval(interval)
}, [])  // ← [] is now honest — the effect has no captured values

The ESLint rule eslint-plugin-react-hooks/exhaustive-deps exists precisely to catch lying dependency arrays. It should always be enabled. When the linter asks you to add something to the array and your instinct is to suppress the warning, that is a signal you need to restructure the code — not silence the linter.

What should go in the dependency array

Everything your effect reads from the component's scope that could change between renders must be in the dependency array. This includes:

// ✓ State variables used inside the effect
useEffect(() => { fetchData(query) }, [query])
 
// ✓ Props used inside the effect
useEffect(() => { document.title = title }, [title])
 
// ✓ Context values used inside the effect
useEffect(() => { applyTheme(theme) }, [theme])
 
// ✓ Functions defined inside the component (they change every render)
const handleData = useCallback(() => { /* ... */ }, [dependency])
useEffect(() => { socket.on('data', handleData) }, [handleData])
 
// ✗ Stable values — do NOT need to be in dependencies:
// - useState setters (React guarantees they are stable)
// - useReducer dispatch (stable)
// - Refs (.current is mutable but the ref object itself is stable)
// - Constants defined outside the component

Cleanup — the half of useEffect most developers get wrong

The cleanup function runs in two scenarios: before the next effect runs (to tear down the previous one), and when the component unmounts. Omitting cleanup causes resource leaks.

// Event listener — cleanup removes it
useEffect(() => {
  window.addEventListener('resize', handleResize)
  return () => window.removeEventListener('resize', handleResize)
}, [handleResize])
 
// Timer — cleanup cancels it
useEffect(() => {
  const id = setTimeout(() => setVisible(false), 3000)
  return () => clearTimeout(id)
}, [])
 
// WebSocket subscription — cleanup unsubscribes
useEffect(() => {
  const sub = stockFeed.subscribe(ticker, onPrice)
  return () => sub.unsubscribe()
}, [ticker])
 
// Fetch with AbortController — cleanup aborts in-flight requests
useEffect(() => {
  const controller = new AbortController()
  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') setError(err)
    })
  return () => controller.abort()
}, [id])

The AbortController pattern is the correct way to handle fetch in useEffect. Without it, if the user navigates away or the id changes before the request completes, the callback runs on an unmounted component — causing the React warning "Can't perform a React state update on an unmounted component."

The stale closure problem — the most common useEffect bug

A stale closure is when your effect captures a value from a render, that value later changes, but the effect still holds the old value because it was not included in the dependency array.

function Counter() {
  const [count, setCount] = useState(0)
 
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count)   // always logs 0 — stale closure
      setCount(count + 1)  // always sets to 1 — never increments
    }, 1000)
    return () => clearInterval(id)
  }, [])  // [] means effect never re-runs — count is permanently captured as 0
 
  return 
{count}
} // Fix 1: Add count to dependencies (but causes interval to restart every second) useEffect(() => { const id = setInterval(() => { setCount(count + 1) }, 1000) return () => clearInterval(id) }, [count]) // Fix 2: Functional update — don't read count at all (best solution) useEffect(() => { const id = setInterval(() => { setCount(prev => prev + 1) // prev is always current — no stale closure }, 1000) return () => clearInterval(id) }, []) // honest [] — no captured values // Fix 3: useRef to hold latest value const countRef = useRef(count) useEffect(() => { countRef.current = count }) // sync ref after every render useEffect(() => { const id = setInterval(() => { setCount(countRef.current + 1) // reads from ref, always current }, 1000) return () => clearInterval(id) }, [])

Data fetching in useEffect — the canonical pattern

function UserProfile({ userId }) {
  const [user, setUser]     = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError]   = useState(null)
 
  useEffect(() => {
    let cancelled = false   // flag to prevent state update after unmount
    const controller = new AbortController()
 
    async function fetchUser() {
      try {
        setLoading(true)
        setError(null)
        const res  = await fetch(`/api/users/${userId}`, { signal: controller.signal })
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json()
        if (!cancelled) setUser(data)    // guard against unmounted component
      } catch (err) {
        if (err.name !== 'AbortError' && !cancelled) setError(err.message)
      } finally {
        if (!cancelled) setLoading(false)
      }
    }
 
    fetchUser()
 
    return () => {
      cancelled = true        // prevent state updates after cleanup
      controller.abort()      // cancel in-flight request
    }
  }, [userId])  // re-fetch when userId changes
 
  if (loading) return 
  if (error)   return 
  return 
}

This pattern handles: data fetching, loading state, error state, request cancellation on userId change, and prevention of state updates on unmounted components. It is the foundation — React Query, SWR, and TanStack Query build on these same concepts with additional caching, deduplication, and background refresh.

useEffect vs useLayoutEffect — when to use which

// useEffect — asynchronous, runs AFTER browser paint
// Use for: data fetching, subscriptions, analytics, document.title
useEffect(() => {
  fetchData()
}, [id])
 
// useLayoutEffect — synchronous, runs BEFORE browser paint (same timing as componentDidMount/Update)
// Use for: reading DOM dimensions, synchronising scroll position, preventing visual flicker
useLayoutEffect(() => {
  // Runs synchronously after DOM mutations, before paint
  const { height } = ref.current.getBoundingClientRect()
  setTooltipHeight(height)   // layout measurement — must happen before paint
}, [])
 
// Classic tooltip positioning example:
function Tooltip({ children, text }) {
  const ref = useRef()
  const [pos, setPos] = useState({ top: 0, left: 0 })
 
  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect()
    setPos({ top: rect.bottom, left: rect.left })
    // With useEffect, tooltip flickers at wrong position then jumps — user sees flash
    // With useLayoutEffect, position is set before paint — no flicker
  }, [])
 
  return 
{children}
}

Functions as dependencies — the object identity problem

Every render creates new function instances. If you pass a function as a dependency, the effect re-runs every render because the function reference is new every time.

// Bug — fetchData is recreated every render, effect runs every render
function SearchResults({ query }) {
  const fetchData = async () => {      // new function every render
    const res = await fetch(`/api/search?q=${query}`)
    setResults(await res.json())
  }
 
  useEffect(() => {
    fetchData()
  }, [fetchData])   // ← triggers every render because fetchData is always new
}
 
// Fix 1: Move the function inside the effect (no dependency needed)
useEffect(() => {
  async function fetchData() {   // defined inside effect — not in outer scope
    const res = await fetch(`/api/search?q=${query}`)
    setResults(await res.json())
  }
  fetchData()
}, [query])   // only query is a dependency
 
// Fix 2: useCallback to stabilise the function reference
const fetchData = useCallback(async () => {
  const res = await fetch(`/api/search?q=${query}`)
  setResults(await res.json())
}, [query])   // function only recreated when query changes
 
useEffect(() => {
  fetchData()
}, [fetchData])

Objects and arrays as dependencies

// Bug — options is a new object every render
function DataChart({ userId }) {
  const options = { userId, format: 'json' }   // new object every render
 
  useEffect(() => {
    fetchChartData(options)
  }, [options])   // new reference every render → effect runs every render
 
  // Fix 1: Destructure — depend on primitives
  useEffect(() => {
    fetchChartData({ userId, format: 'json' })
  }, [userId])   // userId is a primitive — stable reference when unchanged
 
  // Fix 2: useMemo to stabilise the object reference
  const stableOptions = useMemo(() => ({ userId, format: 'json' }), [userId])
  useEffect(() => {
    fetchChartData(stableOptions)
  }, [stableOptions])
}

Race conditions in useEffect

When the same effect fires multiple times rapidly (user types fast, userId changes quickly), earlier requests can resolve after later ones, writing stale data over fresh data.

// Race condition — user types 'r', 're', 'rea', 'reac', 'react'
// Earlier requests may resolve last, overwriting the 'react' results
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(data => setResults(data))   // stale response can win the race
}, [query])
 
// Fix 1: AbortController — cancels the previous request on cleanup
useEffect(() => {
  const controller = new AbortController()
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => setResults(data))
    .catch(err => { if (err.name !== 'AbortError') setError(err) })
  return () => controller.abort()   // cleanup aborts previous request
}, [query])
 
// Fix 2: Ignore flag — let the request complete but ignore the result
useEffect(() => {
  let ignore = false
  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(data => { if (!ignore) setResults(data) })
  return () => { ignore = true }
}, [query])

React 18 and StrictMode double-invocation

In React 18, StrictMode intentionally mounts components twice in development (mount → unmount → remount) to help detect effects that don't clean up properly. This causes useEffect to run twice on mount in development only.

// In React 18 StrictMode, development only:
// 1. Component mounts → effect runs
// 2. Component unmounts → cleanup runs
// 3. Component remounts → effect runs again
// (Only one mount in production)
 
// This double-fire exposes missing cleanups:
useEffect(() => {
  console.log('subscribed')
  return () => console.log('unsubscribed')   // MUST clean up for double-fire to be safe
}, [])
// Console in dev: 'subscribed', 'unsubscribed', 'subscribed'
// If you see doubled API calls in dev, this is why — add abort/cancel in cleanup

Common useEffect anti-patterns

// ❌ Anti-pattern 1: Using useEffect for derived state
function CartTotal({ items }) {
  const [total, setTotal] = useState(0)
  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0))  // extra render!
  }, [items])
  // ✅ Fix: derive directly
  const total = items.reduce((sum, item) => sum + item.price, 0)
}
 
// ❌ Anti-pattern 2: Using useEffect to sync parent and child state
function Parent() {
  const [parentVal, setParentVal] = useState('')
  return 
}
function Child({ value, onChange }) {
  const [localVal, setLocalVal] = useState(value)
  useEffect(() => setLocalVal(value), [value])   // state sync — fight between React and you
  // ✅ Fix: lift state up fully or use a single controlled input
 
// ❌ Anti-pattern 3: Fetching without loading/error states
useEffect(() => {
  fetch('/api/data').then(r => r.json()).then(setData)  // no loading, no error handling
}, [])
 
// ❌ Anti-pattern 4: Not abstracting repeated effect patterns into custom hooks
// If you write the same fetch-loading-error pattern twice, extract a useFetch hook
 
// ❌ Anti-pattern 5: Using useEffect for event handlers
useEffect(() => {
  document.getElementById('btn').addEventListener('click', handleClick)
  return () => document.getElementById('btn').removeEventListener('click', handleClick)
}, [])
// ✅ Fix: use React's onClick prop — that's what it's for

useEffect vs React Query — when to reach for a library

useEffect is the primitive. React Query, SWR, and TanStack Query are abstractions built on top. Use the library when you need:

  • Caching — don't re-fetch data that was recently loaded
  • Deduplication — multiple components requesting the same data fire only one request
  • Background refetching — stale-while-revalidate pattern
  • Optimistic updates — update UI before server confirms
  • Infinite scroll / pagination — built-in cursor management
// The same fetch pattern in React Query — eliminates all the useEffect boilerplate
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],   // cache key — automatically deduplicates
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000,    // treat data as fresh for 5 minutes
  })
 
  if (isLoading) return 
  if (error)     return 
  return 
}

Common Misconceptions

⚠️

Many developers think useEffect is a lifecycle method — but it is a synchronisation tool. The mental model "run on mount", "run on update", "run on unmount" maps poorly to useEffect's actual behaviour and leads to dependency array mistakes. The correct mental model: "what external system needs to stay in sync with these reactive values?"

⚠️

Many developers think an empty dependency array means "run once on mount" — but it means "this effect has no dependencies." Those two things are often the same, but not always. If your effect secretly uses values from the component's scope (count, userId, theme) but you put them in a [] array, you have a stale closure bug, not a "run once" guarantee.

⚠️

Many developers think suppressing the exhaustive-deps ESLint warning is a valid solution — but the warning is always correct. Every suppressed warning hides a latent stale closure bug. The right response to the warning is to restructure the code: use functional state updates, move the function inside the effect, or extract a custom hook.

⚠️

Many developers think cleanup only matters when the component unmounts — but cleanup runs before every re-execution of the effect. If useEffect runs 10 times, cleanup runs 9 times (before runs 2 through 10). This means cleanup must undo whatever the previous effect set up, every single time.

⚠️

Many developers think useEffect is the right tool for derived state — but any value that can be computed directly from props or state should be computed during render, not in an effect. Using useEffect to sync derived state adds an unnecessary extra render and introduces potential for bugs.

⚠️

Many developers think they need useEffect to respond to user events — but event handlers (onClick, onChange, onSubmit) are the right tool for responding to events. useEffect is for synchronising with external systems, not for handling user interactions. Putting event response logic in useEffect usually means one extra render and delayed feedback.

⚠️

Many developers think useEffect runs synchronously after the DOM update — but it runs asynchronously after the browser has already painted. If you need to run code synchronously before the browser paints (to prevent visual flicker), use useLayoutEffect. The difference is visible — useEffect can cause a flash of incorrect content for layout-sensitive operations.

⚠️

Many developers think all functions referenced in an effect must be in the dependency array — but the best fix is usually to move the function inside the effect. Functions defined inside the effect don't need to be in the array because they're created fresh each time the effect runs. Only stable, memoised functions (via useCallback) should be listed as dependencies.

⚠️

Many developers think useEffect in React 18 runs twice in production — but the double invocation only happens in development with StrictMode. In production, effects run exactly once on mount. The double firing is intentional — it surfaces missing cleanups before they cause bugs in production.

⚠️

Many developers think they should use useEffect whenever they need to "do something after state changes" — but most of those cases are better handled by event handlers, useMemo, derived state, or libraries like React Query. The question to ask before writing useEffect: "Is this synchronising an external system?" If the answer is no, there's probably a better tool.

Where You'll See This in Real Code

Spotify's web player uses useEffect with cleanup to manage the Spotify Web Playback SDK. On mount it initialises the player and subscribes to track change events. The cleanup unsubscribes and calls player.disconnect() on unmount. Without cleanup, navigating away from the player page left the SDK consuming CPU and holding an open WebSocket connection.

GitHub's code review interface uses useEffect to sync keyboard shortcuts with the currently active review thread. The dependency array contains the activeThreadId. When users click between comments, the effect re-registers the shortcut handlers for the new active thread and the cleanup removes the previous thread's handlers — preventing duplicate shortcut registrations.

Razorpay's payment gateway widget uses useEffect with an empty dependency array to load the Razorpay checkout script dynamically, initialise the SDK instance, and attach it to the payment button. The cleanup is critical here: if the user navigates away during the checkout flow, cleanup aborts the payment session to prevent ghost transactions.

React Router's useNavigate hook internally uses useEffect to synchronise the browser's history API with React's state. When a programmatic navigation occurs, an effect pushes to the history stack. The cleanup pops the entry if the component unmounts mid-navigation. This is why you should never call navigation APIs directly in render — they must be in effects or event handlers.

Vercel's Next.js analytics module uses useEffect with router.events from next/router to track page views. On every route change event, it fires an analytics call. The cleanup removes the event listener. This is a textbook subscription-in-effect pattern — the router's event emitter is an external system that useEffect synchronises with.

Figma's multiplayer cursor tracking registers a useEffect that subscribes to a WebSocket channel for cursor position updates from other users. The dependency array includes the roomId. When users switch rooms, cleanup unsubscribes from the old room's channel and the new effect subscribes to the new one. Missing this cleanup means receiving cursor events from rooms you've left — ghost cursors.

Linear's issue board uses useEffect to implement keyboard navigation. When a card receives focus (tracked in state), an effect registers ArrowUp/ArrowDown listeners to move between issues. Cleanup removes those listeners when focus moves away. Without cleanup, every card that was ever focused accumulates its own set of arrow key listeners — causing multiple navigation jumps per keypress.

Interview Cheat Sheet

  • useEffect runs AFTER render and AFTER the browser paints — it is asynchronous
  • Three signatures: no array (every render) · [] (once, on mount) · [deps] (when deps change)
  • Dependency array = declaration of what the effect uses — not an optimisation
  • Everything read from component scope that can change must be in the dependency array
  • useState setters, useReducer dispatch, and ref objects are stable — omit from deps
  • Cleanup runs BEFORE the next effect execution AND on unmount — not only on unmount
  • Stale closure: omitting a dependency captures an old value — effect uses stale data forever
  • Functional state update (prev => prev + 1) avoids reading state in effects — no stale closure
  • AbortController is the correct way to cancel fetch on cleanup — prevents race conditions
  • Race condition: rapid dependency changes → earlier responses can overwrite later ones
  • useLayoutEffect: synchronous, before paint — use for DOM measurements to prevent flicker
  • useEffect for data fetching — always handle loading state, error state, and cancellation
  • Moving a function inside the effect avoids needing it in the dependency array
  • useCallback stabilises function references if they must be outside the effect
  • Objects and arrays are new references every render — depend on primitives or useMemo
  • React 18 StrictMode: double-invokes effects in dev to surface missing cleanups
  • Never suppress exhaustive-deps warnings — restructure instead
  • useEffect is wrong for derived state → compute during render instead
  • useEffect is wrong for event responses → use onClick/onChange handlers instead
  • useEffect is wrong for synchronous DOM reads → use useLayoutEffect instead
  • For data fetching at scale: React Query / SWR / TanStack Query > raw useEffect
💡

How to Answer in an Interview

  • 1.Start with the synchronisation mental model — not the lifecycle model. Say: "useEffect is for synchronising external systems with React state. I think of it as 'what needs to stay in sync with these values' rather than 'what should run on mount'." This signals immediate seniority — most candidates answer with lifecycle thinking.
  • 2.When asked about the dependency array, distinguish between the two types of missing dependencies: the lying empty array (stale closure bug) and the dependency array where objects/functions cause infinite re-runs (identity bug). Demonstrating you understand both failure modes, and both fixes, is what separates senior from mid-level answers.
  • 3.Always mention the cleanup function unprompted when discussing useEffect. Describe it as running "before the next effect AND on unmount" — not just on unmount. Most candidates only know half the story. Mentioning specific cleanup examples (AbortController, clearInterval, removeEventListener) shows practical experience.
  • 4.The stale closure counter example is the canonical useEffect interview demonstration. Prepare to: write the bug (var with []), explain why it's stale, show Fix 1 (add to deps), explain its downside (interval restarts), show Fix 2 (functional update with []). Walking through all three shows systematic thinking, not just memorised solutions.
  • 5.When discussing data fetching in useEffect, mention race conditions and AbortController. Most candidates know to handle loading and error state but miss the race condition problem. Describing the scenario (rapid userId changes, earlier response arrives after later response) and the fix (abort on cleanup) is the detail that gets you from "good" to "great" in an interview.
  • 6.Distinguish useEffect from useLayoutEffect clearly: "useEffect runs asynchronously after the browser paints, which is fine for most cases. useLayoutEffect runs synchronously before paint — necessary for DOM measurements where a visual flicker would occur between the paint and the fix." Give the tooltip positioning example.
  • 7.For React 18 questions about double useEffect invocation in development: explain that StrictMode intentionally unmounts and remounts components once in dev to verify that cleanup is correctly written. The production behaviour is unchanged — one mount, effects run once. This shows you understand the deliberate design decision, not just the surprising symptom.
  • 8.When asked about alternatives to useEffect for data fetching, mention React Query or TanStack Query and explain why: caching, deduplication, background refetch, and stale-while-revalidate are hard to implement correctly with raw useEffect. The correct answer is "useEffect for simple cases, a data-fetching library for production apps" — not one or the other absolutely.

Practice Questions

No questions tagged to this topic yet.

Related Topics

useState Hook — Complete React Interview Guide
Beginner·4–8 Qs
useRef Hook — Complete React Interview Guide
Beginner·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz