Why this matters

The bug. React calls the effect's *returned function* on unmount (and before the next run when deps change). Returning undefined means there's no cleanup, so the resize listener accumulates on every mount/remount — and because the closure captures a stale setW, you also keep stale React update queues alive.

The fix. Return a teardown that removes exactly what you added. Pass the same function reference (onResize) to both addEventListener and removeEventListener — passing a fresh arrow to removeEventListener is a silent no-op.

Heuristic. Any effect that subscribes to *anything* — DOM events, intervals, websockets, observables — needs a cleanup. The eslint rule react-hooks/exhaustive-deps won't catch this; only review will.

Review heuristic

Every long-lived collection (cache, registry, event bus, observer list) needs an eviction or unsubscribe path that fires deterministically. "It'll get GC'd" is true for the value but not for the reference holding it.

External reference: CWE-401: Missing Release of Memory after Effective Lifetime.