Back to React
React
medium
mid

How does the useEffect lifecycle work in practice?

useEffect runs AFTER React commits to the DOM. Setup function runs when deps change or on mount; cleanup runs before next setup AND on unmount. Phase: render → commit → useLayoutEffect (sync) → browser paint → useEffect (async). StrictMode dev double-invokes effects to surface missing cleanups. Common bugs: stale closures (use functional updaters), infinite loops (use derived render), missing cleanups (return cleanup function).

7 min read·~5 min to think through

useEffect is React's bridge between component state and the outside world.

When effects fire

The full sequence on a render:

ts
1. Component function executes (top to bottom)
2. JSX is returned
3. Reconciliation
4. Commit (DOM mutations applied)
5. useLayoutEffect runs (synchronous; blocks paint)
6. Browser paints
7. useEffect runs (asynchronous; after paint)

On the NEXT render, cleanups from the previous effects run BEFORE the new setups.

Setup and cleanup

tsx
useEffect(() => {
  // setup
  console.log('setup', dep);
  return () => {
    // cleanup
    console.log('cleanup', dep);
  };
}, [dep]);

Imagine dep going from A to B to C:

ts
mount:   setup(A)
update:  cleanup(A), setup(B)
update:  cleanup(B), setup(C)
unmount: cleanup(C)

Cleanup always sees the values from when its corresponding setup ran (closure).

Dependency array

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

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

StrictMode dev double-mount

In React 18 dev mode, every component effectively runs:

ts
setup → cleanup → setup

This is 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 captured as 0

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: compute in render.

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

Missing dep:

tsx
useEffect(() => {
  doSomethingWith(prop); // prop not in deps — stale
}, []);

Fix: add prop to deps; let the ESLint plugin enforce.

Unstable dep:

tsx
useEffect(() => {...}, [{ filter: 'active' }]); // new object every render

Fix: memoize at the source.

Cleanup is mandatory for…

  • Timers (clearInterval, clearTimeout).
  • Subscriptions (socket.off, emitter.removeListener).
  • DOM listeners (removeEventListener).
  • AbortController for in-flight fetches.

When NOT to use useEffect

  • Deriving state from props → compute in render.
  • Resetting state on prop change → use a key.
  • Initializing state from a prop → useState initializer.
  • Fetching data → use React Query.

useEffect vs useLayoutEffect

useLayoutEffect runs synchronously after commit, before paint. Use only when you must measure or mutate the DOM before the user sees the new UI. Otherwise useEffect — it's async and doesn't block paint.

Lint rule

eslint-plugin-react-hooks's exhaustive-deps catches missing deps. Suppressing is almost always wrong.

Senior framing

useEffect is a synchronization primitive: 'when these inputs change, perform this sync with the outside world'. Most useEffect bugs come from using it as a generic 'do something later' hook — the right answer is usually 'don't useEffect at all' (compute in render, use key reset, use React Query for fetches).

Follow-up questions

  • What's the 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 exhaustive-deps suppression to hide a bug.
  • Forgetting cleanup for subscriptions/timers.
  • Setting state in an effect that depends on that same state.

Performance considerations

  • Effects run after paint, so they don't block the user. Effects that setState cause a second render — costly if frequent. Push derived state out of effects.

Edge cases

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

Real-world examples

  • useEffect for: WebSocket subscriptions, scroll-to-URL sync, window resize listeners, route-change analytics, localStorage sync. Anti-patterns: state-from-props sync, fetching without a library.

Senior engineer discussion

Senior signal: knowing the 'You might not need an effect' page. Most effect bugs disappear when you delete the effect in favor of derived render or a proper data library.

Related questions