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.
useEffect connects React state to the outside world (DOM, timers, subscriptions, network).
The phases
- Render — React calls your component, builds a virtual DOM.
- Commit — React applies the changes to the real DOM.
- Effect — React calls your useEffect setup, AFTER the commit (so the user sees the new DOM first).
- Next render — React calls the cleanup from the previous effect, THEN runs the new setup.
useEffect(() => {
console.log('setup');
return () => console.log('cleanup');
}, [dep]);Sequence when dep changes:
- Component re-renders.
- React commits new DOM.
- React runs previous cleanup ('cleanup').
- React runs new setup ('setup').
Dependency array
useEffect(() => { ... }); // every render
useEffect(() => { ... }, []); // mount only
useEffect(() => { ... }, [a, b]); // when a or b changesReact 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.
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
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count is captured as 0 foreverFix: functional updater.
setCount(c => c + 1);Infinite loop
useEffect(() => {
setUsers(filter(users, q));
}, [users, q]); // setUsers changes users → re-runs foreverFix: derive in render, not in an effect.
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
keyinstead. - 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'.