Implement a polyfill for useEffect
`useEffect(fn, deps)` runs `fn` after commit if deps changed (or every commit if no deps); calls the previous cleanup before each re-run and on unmount. Polyfill: store `{ deps, cleanup }` in the hook slot; in a 'after-render' phase, compare deps; if changed, run cleanup then fn, save new cleanup.
useEffect runs side effects after React commits the rendered output to the DOM. Its model is synchronize with these inputs: re-run when deps change, clean up before each re-run and on unmount.
Behavior
useEffect(() => {
subscribe(userId);
return () => unsubscribe(userId);
}, [userId]);- After every commit where
userIddiffers from the last run, React: calls the previous cleanup, then calls the effect. - On unmount: calls the last cleanup.
- No deps array → runs after every commit.
- Empty
[]→ runs once after mount; cleanup runs on unmount.
The polyfill
Two phases per render: render (collect effects) and commit/flush (run them).
let currentComponent = null;
const pendingEffects = []; // collected during render
function useEffect(fn, deps) {
const c = currentComponent;
const i = c.hookIndex++;
const prev = c.hooks[i];
const changed = !prev || !depsEqual(prev.deps, deps);
c.hooks[i] = { deps, cleanup: prev?.cleanup }; // carry old cleanup forward
if (changed) {
pendingEffects.push(() => {
if (prev?.cleanup) prev.cleanup();
const cleanup = fn();
c.hooks[i].cleanup = typeof cleanup === "function" ? cleanup : undefined;
});
}
}
function flushEffects() {
for (const eff of pendingEffects) eff();
pendingEffects.length = 0;
}
// renderer calls flushEffects() *after* committing the DOMOn unmount, walk the component's hook slots and call any remaining cleanups.
Why this shape
- After commit, not during render — so effects see the DOM in its final state, and don't block the paint.
- Cleanup before re-run — symmetric to setup; ensures no stacked subscriptions.
- Deps array is the contract: "re-sync when these change." Including everything you close over keeps the effect honest.
Common gotchas
Stale closure
useEffect(() => {
const id = setInterval(() => console.log(count), 1000); // count captured at mount
return () => clearInterval(id);
}, []); // count not in deps → always 0Fix: list count in deps (effect re-runs each change), or use a ref for "latest".
Effect runs twice in dev (Strict Mode)
Intentional — React simulates an unmount/remount to surface effects that aren't cleanup-safe.
Setting state in an effect
Fine, but be sure the new state actually differs (setState(v) with same v is a bail-out). Don't create render → effect → setState → render loops without a guard.
useLayoutEffect — the sync variant
Same model, but runs synchronously after commit, before paint — useful for measurements. More expensive; avoid unless you need it.
Interview framing
"useEffect runs side effects after commit. Each effect has deps; on each commit, if deps differ from the previous render (Object.is), React calls the previous cleanup then the new effect; on unmount it calls the last cleanup. The polyfill: each effect slot stores deps + the last cleanup; during render we record the new deps and queue a flush; after commit, we run queued flushes — previous cleanup then new fn. useLayoutEffect is the same engine but runs synchronously after commit before paint, for measurement-sensitive cases."
Follow-up questions
- •Why run effects after commit, not during render?
- •Why does the cleanup run *before* the next effect, not the next render?
- •Difference between useEffect and useLayoutEffect?
- •Why does Strict Mode run effects twice in dev?
Common mistakes
- •Stale closures from omitted deps.
- •Setting state in effects without guards → render loops.
- •Running side effects during render.
- •useLayoutEffect when useEffect would do — blocks paint.
Performance considerations
- •Effects don't block paint. useLayoutEffect does — use sparingly. Stable dep references via useMemo/useCallback prevent unnecessary effect re-runs.
Edge cases
- •Effect that throws — cleanup may not be set.
- •Component unmounted before its effect runs.
- •Effects depending on objects/arrays that get fresh identity each render.
Real-world examples
- •Subscriptions, timers, event listeners, data fetching with abort.
- •useLayoutEffect for tooltip positioning before paint.