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 & CleanupuseEffect 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 whenaorbchange.- 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:
useEffectruns 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.useLayoutEffectruns 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.