React useEffect vs the DOM paint cycle
- Last updated
- Prerequisites:
- React components and hooks basics
- Understanding of render vs commit phases
- react
- hooks
- lifecycle
- effects
- concurrent-rendering
Read at your depth
The practical view
useEffect(callback, deps) runs the callback after the browser has painted the DOM update caused by a render. The dependency array controls when it re-runs: on mount ([]), on every render (no array), or only when listed values change. Cleanup (the returned function) runs before the next effect and on unmount: useEffect(() => { socket.connect(); return () => socket.disconnect(); }, []). Practical pattern: side effects — subscriptions, timers, network fetches, manual DOM reads/writes — belong in effects, never directly in the render body, because the render body must stay pure.
The same idea in other frameworks
useEffect(() => {
const h = () => console.log('resized');
window.addEventListener('resize', h);
return () => window.removeEventListener('resize', h);
}, []);ngOnInit() { this.sub = fromEvent(window, 'resize').subscribe(...) }
ngOnDestroy() { this.sub.unsubscribe(); }onMounted(() => {
const h = () => console.log('resized');
window.addEventListener('resize', h);
onBeforeUnmount(() => window.removeEventListener('resize', h));
});$effect(() => {
const h = () => console.log('resized');
window.addEventListener('resize', h);
return () => window.removeEventListener('resize', h);
});Legacy vs modern
Effect ordering: layout measurement before paint vs after
The legacy approach measured layout in a passive effect after paint (causing a visible flicker); the corrected approach uses useLayoutEffect so the measurement happens before the browser paints.
useEffect(() => {
setWidth(elRef.current.offsetWidth);
}, []);useLayoutEffect(() => {
setWidth(elRef.current.offsetWidth);
}, []);Interview gotchas
Context
This is a favorite probe: the interviewer wants to hear about render loops and the difference between sync state updates and effect-driven state updates.
The mechanical answer
Setting state inside an effect schedules another render, and if the state update causes a different value on the next render while the effect depends on that value, you get an infinite loop (render → effect → setState → render…). React only breaks the loop when the effect's dependencies are stable and the new state equals the previous value (bailout). Acceptable cases are one-time initialization derived from the DOM (measurement), synchronizing with an external store when the store's value differs, or data fetching with a guard. The architectural fix is usually deriving state during render (no effect) or lifting the source of truth into a reducer/selector.
Trap
The trap answer is 'setState in effects is always forbidden'. The real rule is about loops and derived-state correctness: an effect that calls setState with a value that depends on the effect's own deps will ping-pong forever. Also note setState in an effect is technically allowed but React may warn under StrictMode double-invocation — interviewers like hearing you know the double-effect behavior is intentional for surfacing impure effects.
Context
This checks precise knowledge of the React pipeline: render, commit, paint, and where hooks actually execute.
The mechanical answer
After render produces a new fiber tree, React reconciles and commits: it mutates the DOM (insert/update/remove), then layout effects (useLayoutEffect) run synchronously before the browser paint so they can read and write layout atomically. Then React yields to the browser, which paints the committed DOM. After paint, passive effects (useEffect) are flushed in a scheduled task. The practical consequence: reading offsetHeight in useEffect gives post-paint values (with a potential flicker if you then setState), while useLayoutEffect gives pre-paint values. React deliberately defers passive effects so heavy work in them never blocks the first frame.
Trap
Saying 'useEffect runs after the render' is imprecise enough to be a red flag — it runs after commit AND after paint, and the commit (DOM mutation) is the more meaningful boundary for whether the DOM is up to date. Another trap: assuming effects run after every commit immediately; they are batched and scheduled, and with concurrent features they can be delayed or even skipped for discarded renders.