useEffect Interview Questions & Answers

35 questions Updated 2026-06-17

React useEffect interview questions and answers — the dependency array, cleanup functions, effect timing and common mistakes.

Read the in-depth guideReact useEffect Hook — A Complete Guide to Effects, Dependencies & Cleanup

useEffect lets you run side effects — work that reaches outside React's render output: fetching data, setting up subscriptions or timers, manually touching the DOM, logging. Render must stay pure (no side effects), so React gives you useEffect as the escape hatch that runs after the component has rendered and the screen is updated.

useEffect(() => {
  document.title = `${count} unread`
}, [count])

The mental model isn't "run this on mount/update" but "keep this external thing in sync with the state and props in my dependency array." React runs the effect whenever one of those inputs changes so the outside world matches the latest render.

The dependency array tells React when to re-run the effect by comparing each item to its value from the previous render (using Object.is):

  • [] -> run once after the first render (no dependencies ever change).
  • [a, b] -> run after the first render, then again only when a or b change.
  • omitted -> run after every render.
useEffect(() => {
  const sub = source.subscribe(id)
  return () => sub.unsubscribe()
}, [id]) // re-subscribe only when `id` changes

The golden rule (enforced by the react-hooks/exhaustive-deps lint): every reactive value the effect reads — props, state, derived values — must be listed. Omitting one to "run less often" is the #1 source of stale-data bugs.

If your effect sets something up that needs tearing down, return a function from it — that's the cleanup. React runs it in two situations: before re-running the effect (to undo the previous run) and when the component unmounts. This prevents leaked listeners, duplicate subscriptions, and "setState on unmounted component" warnings.

useEffect(() => {
  const id = setInterval(tick, 1000)
  return () => clearInterval(id) // tear down before next run / on unmount
}, [])

The sequence on a dependency change is: run cleanup for the old deps -> run the effect for the new deps. So with [id], changing id from 1 to 2 unsubscribes from 1 first, then subscribes to 2. Forgetting cleanup is how you end up with N intervals firing after N renders.

An empty array [] means "no reactive dependencies," so the effect runs once after the initial render and its cleanup runs once on unmount. It's the closest hooks equivalent to the old componentDidMount + componentWillUnmount lifecycle pair.

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth)
  window.addEventListener('resize', onResize)
  return () => window.removeEventListener('resize', onResize)
}, []) // attach once, detach on unmount

Caveat for interviews: in React 18 Strict Mode during development, React intentionally mounts -> unmounts -> remounts components, so your [] effect and its cleanup fire twice. That's a deliberate check that your cleanup is correct; it does not happen in production.

An effect closes over the props and state from the render it was created in. If you read a value but leave it out of the dependency array, the effect keeps using the frozen value from that render and never sees updates — a stale closure.

// count is captured once (as 0) and never updated -> logs 0, 0, 0...
useEffect(() => {
  const id = setInterval(() => console.log(count), 1000)
  return () => clearInterval(id)
}, []) // missing `count`

// Option A: list the dependency (re-creates the interval each change)
// Option B: use a functional updater so you don't read `count` at all
setCount(c => c + 1)

Fixes: include every value you read in the deps, use the functional updater form so the stale value never matters, or stash the latest value in a useRef when you deliberately want a long-lived effect that reads fresh data.

Both run after render, but at different moments relative to the browser paint:

  • useEffect runs asynchronously, after the browser has painted. The user sees the new frame, then the effect fires. Best for the vast majority of effects (data, subscriptions) since it doesn't block visual updates.
  • useLayoutEffect runs synchronously after DOM mutations but before paint. React blocks painting until it finishes, so you can measure layout or mutate the DOM and the user never sees an intermediate state.
useLayoutEffect(() => {
  const { height } = ref.current.getBoundingClientRect()
  setTooltipTop(height) // adjust position before the browser paints
}, [])

Use useLayoutEffect only when you must read/write layout to avoid a visible flicker; because it's blocking, overusing it hurts performance. (On the server it doesn't run and warns — guard SSR code accordingly.)

Start the request in the effect and store the result in state. List every value the request depends on (like an id) in the dependency array so it refetches when they change.

useEffect(() => {
  let active = true
  fetch(`/api/user/${id}`)
    .then(r => r.json())
    .then(data => { if (active) setUser(data) })
  return () => { active = false } // ignore a stale response
}, [id])

The active flag (or an AbortController) prevents a slow earlier request from overwriting a newer one. In real apps a data library usually handles this better.

If id changes quickly, responses can arrive out of order and the older one overwrites the newer. Guard with a cleanup that invalidates the in-flight request — an ignore flag or an AbortController.

useEffect(() => {
  const ctrl = new AbortController()
  fetch(`/api/${id}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(setData)
    .catch(e => { if (e.name !== 'AbortError') throw e })
  return () => ctrl.abort() // cancel the previous request
}, [id])

React runs the cleanup before the next effect, so the stale request is aborted and can't clobber fresh data.

Hand-rolled useEffect fetching means you reimplement caching, deduping, retries, race-condition handling, loading/error states, and refetching — for every call. Libraries like React Query / SWR / RTK Query give you all of that declaratively.

const { data, isLoading, error } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
})

The React docs explicitly recommend a framework or data library for fetching. Use raw effect-fetching only for simple, one-off cases.

Dependencies are compared by reference (Object.is). An object/array literal created during render is a new reference each time, so the effect sees a "changed" dependency on every render and re-runs endlessly.

// options is a new object every render -> effect runs every render
const options = { id }
useEffect(() => subscribe(options), [options])

Fixes: depend on the primitive inside ([id]), build the object inside the effect, or memoize it with useMemo. Same applies to functions passed as deps — stabilize with useCallback.

A function defined in a component body is recreated each render (new reference). If an effect depends on it, the effect re-runs every render. useCallback returns a stable function identity that only changes when its own deps do.

const load = useCallback(() => fetchData(id), [id])
useEffect(() => { load() }, [load]) // re-runs only when id changes

Without it, the effect would fire on every render. useCallback is mainly about referential stability for deps and memoized children — not raw speed.

Wrap the object's creation in useMemo so it keeps the same reference until its inputs change, which keeps a dependent effect from re-running needlessly.

const filters = useMemo(() => ({ status, sort }), [status, sort])
useEffect(() => {
  applyFilters(filters)
}, [filters]) // only when status or sort actually change

Equivalent alternative: skip the object and depend on [status, sort] directly. Memoize when the object must be passed around as a single value.

Set up the interval in the effect and clear it in cleanup so it doesn't leak or duplicate when deps change or the component unmounts.

useEffect(() => {
  const id = setInterval(() => refetch(), 5000)
  return () => clearInterval(id)
}, [refetch])

If refetch isn't stable, wrap it in useCallback or you'll tear down and recreate the interval each render. For variable intervals, a useRef-based useInterval hook is a common pattern.

Start a timer in the effect that updates a "debounced" state, and clear the timer in cleanup — so rapid changes keep resetting the timer until input settles.

const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState(query)
useEffect(() => {
  const id = setTimeout(() => setDebounced(query), 300)
  return () => clearTimeout(id) // cancel if query changes again
}, [query])
// run search on `debounced`, not `query`

Each keystroke re-runs the effect, whose cleanup cancels the previous pending timer — so the update only fires after 300ms of quiet.

Subscribe in the effect and unsubscribe in cleanup. For external stores, React 18's useSyncExternalStore is the purpose-built hook (tear-free with concurrent rendering), but a manual effect works for simple cases.

useEffect(() => {
  const unsub = store.subscribe(() => setValue(store.get()))
  setValue(store.get())   // sync the initial value
  return unsub            // cleanup unsubscribes
}, [store])

Always read the current value once on subscribe so you don't miss a change that happened between render and effect. Prefer useSyncExternalStore for shared stores.

Write to localStorage in an effect that depends on the value, and read it lazily in the initializer so it's only parsed once.

const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light')
useEffect(() => {
  localStorage.setItem('theme', theme)
}, [theme])

The lazy initializer avoids reading storage on every render; the effect persists changes. Guard localStorage access for SSR (it doesn't exist on the server) — effects don't run server-side, so this pattern is SSR-safe.

Effects are for synchronizing with external systems, not for reacting to user events or computing values. If something happens because the user did something, do it in the event handler, not an effect.

// effect reacting to a click that already happened
useEffect(() => { if (submitted) postData() }, [submitted])
// just do it in the handler
function onSubmit() { postData() }

"You Might Not Need an Effect" (React docs): skip effects for derived data, event responses, and resetting state on prop change (use key instead).

Storing computed data in state and syncing it with an effect causes an extra render and risks the copy going stale. Just compute it during render.

// effect + extra state + extra render
const [full, setFull] = useState('')
useEffect(() => setFull(`${first} ${last}`), [first, last])

// derive in render (memoize only if expensive)
const full = `${first} ${last}`

An effect-to-set-state for something derivable is a classic anti-pattern flagged in the React docs.

Adding // eslint-disable-next-line react-hooks/exhaustive-deps to silence a missing dependency doesn't fix the bug — it hides it. The effect keeps using stale captured values and silently breaks when those values change.

// suppressing the warning to "run once" -> stale `userId`
useEffect(() => { fetchData(userId) }, []) // eslint-disable-line

Instead, fix the root cause: include the dep, move logic inside the effect, use a functional setter, or stabilize the value with useCallback/useRef. Suppress only when you fully understand why it's safe.

On a dependency change, React runs the previous effect's cleanup first, then runs the new effect. It never overlaps them.

useEffect(() => {
  console.log('subscribe', id)
  return () => console.log('unsubscribe', id)
}, [id])
// id: 1 -> 2 logs: "unsubscribe 1" then "subscribe 2"

This ordering guarantees you tear down the old subscription before setting up the new one — no leaks, no duplicates. On unmount, only the last cleanup runs.

In React 18 Strict Mode (development only), React mounts each component, unmounts it, and remounts it to surface effects that aren't cleanup-safe. So a mount effect + its cleanup fire twice.

useEffect(() => {
  console.log('run')        // logs twice in dev Strict Mode
  return () => console.log('cleanup')
}, [])

It does not happen in production. If double-invocation breaks something (e.g. duplicate requests), that's a sign your effect needs proper cleanup or shouldn't be an effect at all — Strict Mode is doing its job.

Setting state in an effect whose dependency changes as a result of that state — or using an unstable object/array/function dependency created each render.

// sets data -> re-render -> new [] dep -> runs again -> loop
useEffect(() => setData(compute()), [{}])

// depends on the state it updates
useEffect(() => setCount(count + 1), [count])

Fixes: stabilize deps (memoize objects/functions), depend on primitives, or use a functional updater so the effect needn't depend on the state it sets.

An async function returns a Promise, but React expects the effect callback to return either nothing or a cleanup function. Returning a Promise breaks cleanup. Define an async function inside and call it.

// async effect returns a Promise, not a cleanup fn
useEffect(async () => { await load() }, [])

// inner async function
useEffect(() => {
  (async () => { await load() })()
}, [])

This keeps the return value available for cleanup while still letting you use await inside.

Add the listener in the effect and remove the same function reference in cleanup, so it's detached on unmount and not duplicated on re-runs.

useEffect(() => {
  const onKey = e => { if (e.key === 'Escape') close() }
  window.addEventListener('keydown', onKey)
  return () => window.removeEventListener('keydown', onKey)
}, [close])

The cleanup must reference the exact function passed to addEventListener (an inline arrow in both calls won't match), which is why it's defined once inside the effect.

Yes — use separate effects for unrelated concerns, each with its own dependency array. One effect per responsibility is clearer and avoids re-running unrelated logic when only one dependency changes.

useEffect(() => { document.title = title }, [title])     // title sync
useEffect(() => {
  const id = connect(roomId)
  return () => disconnect(id)
}, [roomId])                                             // connection

Don't cram title updates and a subscription into one effect just because they're in the same component — split by what they synchronize.

The sequence is: React renders (calls your component) -> commits changes to the DOM -> browser paints -> then useEffect runs asynchronously. So effects never block the visual update.

render -> commit (DOM updated) -> paint -> useEffect

This is why you shouldn't read final layout measurements that must be applied before paint in useEffect — use useLayoutEffect for those. For most work (fetching, subscriptions, logging), running after paint is exactly what you want.

Mirror the value in a useRef updated each render, and read ref.current inside the effect. The effect can keep an empty dep array (set up once) yet always see fresh data.

const cbRef = useRef(onTick)
useEffect(() => { cbRef.current = onTick }) // keep it current
useEffect(() => {
  const id = setInterval(() => cbRef.current(), 1000)
  return () => clearInterval(id)
}, []) // interval created once, always calls the latest onTick

This is the core of the useInterval/useEventCallback patterns — avoid stale closures without tearing down the subscription on every change.

Pass an empty dependency array. The effect runs after the first render and never again (its cleanup runs on unmount).

useEffect(() => {
  analytics.pageView()
}, []) // mount only

Caveat: in dev Strict Mode it runs twice, and the lint rule will warn if the effect actually uses props/state you left out — so "run once" should genuinely have no reactive dependencies.

Primitives (string, number, boolean) compare by value, so equal values are "unchanged" and the effect doesn't re-run. Objects/arrays/functions compare by reference, so a freshly created one looks changed every render.

useEffect(() => {}, [userId])        // re-runs only when the number changes
useEffect(() => {}, [{ userId }])    // new object each render -> always re-runs

Prefer depending on the specific primitive fields you read rather than a whole object — it's both safer and more precise.

No. useEffect (and useLayoutEffect) do not run on the server — they only run in the browser after hydration. So effects are the right place for browser-only APIs (window, localStorage, IntersectionObserver).

useEffect(() => {
  const mq = window.matchMedia('(min-width: 768px)') // browser-only, safe here
  // ...
}, [])

Don't access window/document during render (it crashes SSR); defer it to an effect. useLayoutEffect additionally warns during SSR — guard or use useEffect there.

Avoid the effect-that-resets-state pattern. Prefer changing the key to remount the subtree, or the conditional set-during-render pattern for partial resets.

// remount on userId change -> all state resets
<Profile key={userId} userId={userId} />

// partial reset during render (no effect, no extra paint)
const [prevId, setPrevId] = useState(id)
if (id !== prevId) { setPrevId(id); setComment('') }

Using an effect to watch the prop and call setters causes an extra render and is flagged as an anti-pattern in the docs.

A boolean captured in the effect, flipped in cleanup, lets the async callback check whether the component is still mounted (and the request still current) before calling a setter.

useEffect(() => {
  let ignore = false
  load(id).then(data => { if (!ignore) setData(data) })
  return () => { ignore = true } // later resolution is ignored
}, [id])

This both prevents the "can't update an unmounted component" warning and avoids a stale earlier request overwriting newer data.

An effect that sets state, which triggers another effect that sets more state, creates cascading renders that are hard to follow and inefficient — each step is a separate render pass.

// effect -> setState -> effect -> setState ...
useEffect(() => setB(a + 1), [a])
useEffect(() => setC(b * 2), [b])

Prefer computing the values together during render (derive b and c from a), or do the multi-step update in a single event handler. Reserve effects for genuine external synchronization, not internal state cascades.

Rendering must be pure — given the same props/state it returns the same JSX with no side effects. React may call your component multiple times, bail out, or discard renders (concurrent features), so a side effect in render could run unexpectedly, repeatedly, or never.

// side effect during render — runs on every render, breaks purity
document.title = title
// after commit, controlled by deps
useEffect(() => { document.title = title }, [title])

Keeping effects out of render is what lets React safely re-render and optimize.

Use an effect that depends on that value — it runs after each commit where the value changed.

useEffect(() => {
  analytics.track('step_changed', { step })
}, [step])

This is the hooks replacement for the class setState callback or componentDidUpdate comparisons: react to the new value in an effect keyed on it. Make sure the dependency is the exact value you're reacting to.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.