React · Hooks

React useEffect Hook — A Complete Guide to Effects, Dependencies & Cleanup

7 min read Updated 2026-06-17

Practice useEffect interview questions

A complete guide to useEffect

useEffect is where React meets the outside world. Rendering is supposed to be a pure calculation of UI from state and props — but real apps also fetch data, set up subscriptions, start timers, and read from the DOM. Those are side effects, and useEffect is the hook that runs them safely, after render, and keeps them in sync with your data. It's also the hook people misuse the most. This guide builds an accurate mental model and then walks through dependencies, cleanup, timing, and the patterns and pitfalls that come up again and again.

What an effect actually is

The single best framing comes from the React docs: an effect lets you synchronize a component with an external system. Don't think "run this code on mount/update." Think "keep this outside thing (the document title, a subscription, a network request) matching my current state and props."

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

React runs the effect after it has rendered and committed changes to the DOM and the browser has painted. That ordering is deliberate: effects never block the visual update, so the user sees the new frame first.

The dependency array

The second argument controls when the effect re-runs. React compares each item to its value from the previous render using Object.is:

  • [] — run once after the first render.
  • [a, b] — run after the first render and whenever 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 non-negotiable rule, enforced by the react-hooks/exhaustive-deps lint: every reactive value the effect reads must appear in the array. Leaving one out to "run less often" is the number-one cause of stale-data bugs.

Why object and function dependencies misbehave

Dependencies are compared by reference. An object, array, or function created during render is a new reference every render, so the effect thinks it changed and re-runs endlessly.

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

Fix it by depending on the primitive inside ([id]), building the object inside the effect, or stabilizing it with useMemo/useCallback:

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

Prefer depending on specific primitive fields over whole objects — it's both safer and more precise.

Cleanup functions

If an effect sets something up, it should tear it down. Return a function and React runs it before the next effect run and on unmount.

useEffect(() => {
  const id = setInterval(tick, 1000)
  return () => clearInterval(id)
}, [])

The ordering on a dependency change is: run the previous cleanup, then run the new effect. With [id], changing id from 1 to 2 unsubscribes from 1 first, then subscribes to 2 — no leaks, no duplicates. Forgetting cleanup is how you end up with N intervals firing after N renders.

An empty array [] makes the effect run once and its cleanup run on unmount — the hooks equivalent of componentDidMount + componentWillUnmount.

Effect timing: useEffect vs useLayoutEffect

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

  • useEffect runs asynchronously, after the browser paints. Right for almost everything — data, subscriptions, logging.
  • useLayoutEffect runs synchronously after DOM mutations but before paint, so you can measure or adjust layout without a visible flicker.
useLayoutEffect(() => {
  const { height } = ref.current.getBoundingClientRect()
  setTooltipTop(height) // applied before the browser paints
}, [])

Use useLayoutEffect only when you must read/write layout to avoid flicker; because it blocks painting, overusing it hurts performance. Neither runs during server-side rendering, which makes effects the right home for browser-only APIs (window, localStorage).

Fetching data correctly

Start the request in the effect, store the result in state, and guard against race conditions — if the input changes quickly, an older response can overwrite a newer one.

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 in-flight request on change/unmount
}, [id])

The cleanup aborts the previous request before the next one starts, so stale data can't clobber fresh data. The simpler "ignore flag" variant flips a boolean in cleanup and checks it before calling the setter. For real apps, the React docs recommend a data library (React Query, SWR) that handles caching, deduping, retries, and race conditions for you — hand-rolled fetching reimplements all of that.

Stale closures

An effect closes over the props and state from the render it was created in. Read a value but omit it from the deps, and the effect keeps using the frozen value forever.

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

Fixes: include the dependency (which re-creates the interval), use a functional state updater so you never read the stale value, or — for a deliberately long-lived effect — keep the latest value in a useRef updated each render and read ref.current inside.

const cbRef = useRef(onTick)
useEffect(() => { cbRef.current = onTick })       // always current
useEffect(() => {
  const id = setInterval(() => cbRef.current(), 1000)
  return () => clearInterval(id)
}, [])                                            // set up once, always fresh

You might not need an effect

The most common mistake is reaching for an effect when you don't need one. Two rules:

Don't use an effect for things that happen in response to a user event. Do those in the event handler.

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

Don't use an effect to compute derived state. Computing a value and syncing it via an effect causes an extra render and risks the copy going stale — derive it during render, memoizing only if it's expensive.

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

Effects are for synchronizing with external systems, not for internal state cascades. Chains of effects that set state and trigger more effects are an anti-pattern; compute values together in render or do multi-step updates in one handler.

Common pitfalls

  • Disabling the lint to "run once" hides a stale-closure bug — fix the root cause instead of silencing exhaustive-deps.
  • Infinite loops come from setting state in an effect whose dependency changes as a result, or from an unstable object/array/function dependency.
  • The effect callback can't be async — an async function returns a Promise, which breaks cleanup. Define an inner async function and call it.
  • Strict Mode in development mounts -> unmounts -> remounts, so your [] effect and cleanup fire twice. That's a deliberate check that cleanup is correct; it doesn't happen in production.
// async work without breaking cleanup
useEffect(() => {
  let ignore = false
  ;(async () => {
    const data = await load(id)
    if (!ignore) setData(data)
  })()
  return () => { ignore = true }
}, [id])

Recap

useEffect synchronizes your component with the outside world, running after paint and re-running based on its dependency array — which must list every reactive value the effect reads. Return a cleanup function to tear down subscriptions, timers, and requests; React runs it before each re-run and on unmount. Reach for useLayoutEffect only when you must touch layout before paint. Guard data fetching against race conditions, avoid stale closures by listing deps or using refs, and — most importantly — don't use an effect for event responses or derived state. Internalize those ideas and effects stop being the scary hook.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.