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).
useEffect is React's bridge between component state and the outside world.
When effects fire
The full sequence on a render:
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
useEffect(() => {
// setup
console.log('setup', dep);
return () => {
// cleanup
console.log('cleanup', dep);
};
}, [dep]);Imagine dep going from A to B to C:
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
useEffect(() => {...}); // every render
useEffect(() => {...}, []); // mount/unmount only
useEffect(() => {...}, [a, b]); // when a or b changesReact 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:
setup → cleanup → setupThis 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:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count captured as 0Fix: functional updater.
setCount(c => c + 1);Infinite loop:
useEffect(() => {
setUsers(filter(users, q));
}, [users, q]); // setUsers changes users → re-runs foreverFix: compute in render.
const filtered = useMemo(() => filter(users, q), [users, q]);Missing dep:
useEffect(() => {
doSomethingWith(prop); // prop not in deps — stale
}, []);Fix: add prop to deps; let the ESLint plugin enforce.
Unstable dep:
useEffect(() => {...}, [{ filter: 'active' }]); // new object every renderFix: 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.