Day 22: Component Lifecycle vs useEffect Mechanics & Cleanups
Stop mapping useEffect to componentDidMount/Update/Unmount by rote — understand it as "synchronize with an external system" and reason correctly about dependency arrays.
Study
Concepts
useEffect is a synchronization tool, not a lifecycle hook
Class lifecycle methods (`componentDidMount`, `componentDidUpdate`, `componentWillUnmount`) are organized around WHEN in the component's life something happens. `useEffect` is organized around WHAT you are keeping in sync with an external system (a subscription, a DOM API, a timer, a network request) — it runs after every commit where a listed dependency changed, and it is meant to be re-run from scratch each time, not incrementally patched like componentDidUpdate encouraged.
The dependency array is not an optimization knob you tune to control WHEN the effect runs — it is a correctness contract: it must include every reactive value (props, state, or anything derived from them) that the effect body reads, or the effect will capture and act on stale data ("stale closure" bug). ESLint's react-hooks/exhaustive-deps exists specifically to catch violations of this contract.
Cleanup runs before the NEXT effect and on unmount
The function returned from an effect is the cleanup. React calls it before running the effect again (for any dependency change) AND when the component unmounts. Mentally, every effect + its cleanup describes "start synchronizing" / "stop synchronizing" — think of it as one continuous subscription that gets torn down and restarted whenever its dependencies change, not two separate lifecycle events.
Effects run AFTER the browser paints by default (non-blocking) — for the rare case where you must measure/mutate the DOM before the user sees a flicker (e.g. positioning a tooltip), use `useLayoutEffect`, which runs synchronously after DOM mutations but before paint, at the cost of blocking that paint.
See It
Visualizations
Visualization
Effect + cleanup across renders
Mount
Effect runs
subscribes with initial props/state
Update (dep changed)
Cleanup runs, then Effect runs again
unsubscribe old, subscribe new — full restart, not a patch
Update (dep unchanged)
Effect skipped entirely
React compares deps with Object.is per item
Unmount
Cleanup runs
final teardown, no re-run after
Build It
Code Examples
The stale-closure bug, and the exhaustive-deps fix
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
// BUG: roomId is used inside the effect but missing from deps —
// if roomId changes, this effect does NOT re-subscribe, and the
// socket handler keeps referencing the OLD roomId forever.
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on('message', (msg) => setMessages((prev) => [...prev, msg]));
return () => socket.disconnect();
}, []); // <- missing roomId
// FIX: include roomId — effect now correctly tears down and
// re-subscribes to the new room whenever roomId changes.
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on('message', (msg) => setMessages((prev) => [...prev, msg]));
return () => socket.disconnect();
}, [roomId]);
}useEffect vs useLayoutEffect — when the flicker matters
function Tooltip({ targetRef }) {
const [top, setTop] = useState(0);
// useLayoutEffect runs BEFORE the browser paints, so measuring and
// repositioning here never causes a visible one-frame jump.
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setTop(rect.bottom + 8);
}, [targetRef]);
// useEffect would also work, but the tooltip could flash at the
// wrong position for one frame before correcting itself.
return <div style={{ position: 'absolute', top }}>Tip</div>;
}Remember
Key Takeaways
- Think "synchronize with an external system", not "run code at mount/update/unmount".
- The dependency array is a correctness contract — it must list every reactive value the effect reads.
- Cleanup runs before every re-run of the effect AND on unmount — model it as one continuous subscribe/unsubscribe cycle.
- useEffect runs after paint (non-blocking); useLayoutEffect runs before paint (blocking, only for measure-then-mutate cases).
- React skips an effect only when EVERY dependency is Object.is-equal to the previous render — a new object/array literal always "changes".
Do It
Practice
- 1Reproduce the stale-closure bug live, watch it fail with the eslint-plugin-react-hooks warning, then fix it.
- 2Build a tooltip component and deliberately swap useLayoutEffect for useEffect to see (and record) the visible flicker.
- 3Write an effect that subscribes to window resize events with correct cleanup, and prove via console logs it unsubscribes exactly once per unmount.