Back to React
React
easy
mid

How would you implement a polyfill for the useEffect hook?

`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.

5 min read·~25 min to think through

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

js
useEffect(() => {
  subscribe(userId);
  return () => unsubscribe(userId);
}, [userId]);
  • After every commit where userId differs 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).

js
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 DOM

On 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

jsx
useEffect(() => {
  const id = setInterval(() => console.log(count), 1000);  // count captured at mount
  return () => clearInterval(id);
}, []);   // count not in deps → always 0

Fix: 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.

Senior engineer discussion

Seniors think in synchronization, write effects with full deps + symmetric cleanup, reach for refs or external stores when deps churn, and use useLayoutEffect only when measurement before paint is required.

Related questions