Back to React
React
medium
mid

Can you explain the lifecycle of the useEffect hook?

useEffect runs AFTER React commits to the DOM. The setup function runs when deps change (or on mount if deps are empty). The cleanup returned from setup runs BEFORE the next setup AND on unmount. Missing deps cause stale closures; mutable deps cause infinite loops; effects that mutate refs don't trigger re-renders.

7 min read·~5 min to think through

useEffect connects React state to the outside world (DOM, timers, subscriptions, network).

The phases

  1. Render — React calls your component, builds a virtual DOM.
  2. Commit — React applies the changes to the real DOM.
  3. Effect — React calls your useEffect setup, AFTER the commit (so the user sees the new DOM first).
  4. Next render — React calls the cleanup from the previous effect, THEN runs the new setup.
tsx
useEffect(() => {
  console.log('setup');
  return () => console.log('cleanup');
}, [dep]);

Sequence when dep changes:

  1. Component re-renders.
  2. React commits new DOM.
  3. React runs previous cleanup ('cleanup').
  4. React runs new setup ('setup').

Dependency array

tsx
useEffect(() => { ... });            // every render
useEffect(() => { ... }, []);        // mount only
useEffect(() => { ... }, [a, b]);    // when a or b changes

React compares deps with Object.is. Arrays/objects with the same contents but different identity look 'changed' — that's the source of most infinite loops.

Cleanup is mandatory for…

  • Timers: clearInterval, clearTimeout.
  • Subscriptions: socket.off, emitter.removeListener.
  • DOM listeners: removeEventListener.
  • AbortController for in-flight fetches.
tsx
useEffect(() => {
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(...);
  return () => ctrl.abort();
}, [url]);

StrictMode double-mount

In dev with React 18, every effect runs setup → cleanup → setup once on mount. That's intentional: it surfaces effects that don't clean up correctly. If your code breaks under that, fix the cleanup — don't disable StrictMode.

Common bugs

Stale closure

tsx
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []); // count is captured as 0 forever

Fix: functional updater.

tsx
setCount(c => c + 1);

Infinite loop

tsx
useEffect(() => {
  setUsers(filter(users, q));
}, [users, q]); // setUsers changes users → re-runs forever

Fix: derive in render, not in an effect.

tsx
const filtered = useMemo(() => filter(users, q), [users, q]);

Effect lint rule

eslint-plugin-react-hooks's exhaustive-deps rule will flag missing deps. Suppressing it is almost always wrong.

When NOT to use useEffect

  • Deriving state from props: compute in render.
  • Resetting state on prop change: use key instead.
  • Initializing state from a prop: useState(() => prop) initializer.

Effect ordering

useLayoutEffect runs BEFORE paint (synchronous after commit). Use only when you must measure or mutate the DOM before the browser paints — otherwise prefer useEffect.

Follow-up questions

  • Difference between useEffect and useLayoutEffect?
  • Why does StrictMode double-invoke effects in dev?
  • How do you cancel an in-flight fetch when deps change?

Common mistakes

  • Adding the exhaustive-deps suppression to hide a bug instead of fixing it.
  • Forgetting to clean up subscriptions/timers — memory leak.
  • Setting state inside an effect that depends on that same state — infinite loop.

Performance considerations

  • Effects run after paint, so they don't block the user. But effects that synchronously trigger setState cause a second render and re-commit — costly if frequent. Push derived state out of effects entirely where possible.

Edge cases

  • Effects don't run during SSR — they only run on the client after hydration.
  • Effect order matters within a component: declared order = execution order.
  • If a parent unmounts mid-effect, cleanups fire bottom-up.

Real-world examples

  • useEffect for: subscribing to a WebSocket, syncing scroll position to URL, attaching window resize listeners, logging analytics on route change, syncing to localStorage. Anti-patterns are 'setting state from props' and 'fetching data without a library'.

Senior engineer discussion

Senior signal: knowing the React docs page 'You might not need an effect' by heart. Most useEffect bugs disappear when the effect itself is deleted in favor of derived render output or proper data libraries.

Related questions