Master useEffect: dependency array, cleanup, stale closures, data fetching, race conditions, and every pattern asked in React interviews.
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.
React renders in two phases:
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)
// 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 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.
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
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."
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)
}, [])
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 — 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}
}
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])
// 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])
}
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])
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
// ❌ 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 is the primitive. React Query, SWR, and TanStack Query are abstractions built on top. Use the library when you need:
// 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
}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.
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.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.