If the parent passes an inline onClose={() => setOpen(false)} — and it almost always does — onClose is a new function on every render, so this effect tears the listener down and adds a fresh one on every single render of the parent. Drop onClose from the deps to stop the churn and you get the other bug: the listener now holds the first render's onClose forever, and closing the modal calls a stale closure.
You can't win this with a dependency array, because the two things you want are in direct conflict: subscribe once, but always run the newest handler. The fix is to separate them — register the listener on a stable identity, and call through a ref that's kept current. useEventListener from @reactuses/core is that split, packaged. This post covers what it actually does under the hood, the four ways to name a target, exactly what TypeScript infers for each one (this part surprises people), the options that don't retrigger, and the two gotchas worth knowing before you ship it.
That's the whole fix. No dependency array, no useCallback on the parent, no cleanup to remember. The listener is added to window once when the component mounts and removed when it unmounts; the arrow function you passed is re-created on every render and it doesn't matter, because the listener never re-registers — it calls the latest one. e is a KeyboardEvent, inferred, not annotated.
target defaults to window. options is the same boolean | AddEventListenerOptions you'd pass to addEventListener.
The implementation is short enough to read in full, and worth reading because every line is answering one of the problems above:
The handler is held in a ref, not in the deps. useLatest keeps savedHandler.current pointing at the newest handler after every committed render, and the function actually registered with the DOM is a thin wrapper that forwards to it. So the handler you pass can be a brand-new closure every render — inline arrow functions are not just allowed, they're the expected usage — while addEventListener is called exactly once. That's the "subscribe once, run the newest" split, and it's why handler is deliberately absent from the dependency list.
The dependency list is deep-compared. The effect is useDeepCompareEffect, not useEffect, so a fresh-but-identical options object each render doesn't count as a change. Writing useEventListener("scroll", onScroll, ref, { passive: true }) with the object literal inline is fine: three renders, one addEventListener call. Change the contents to { passive: false } and it does re-register, which is what you want.
The target is resolved inside the effect, at commit time. getTargetElement runs in the effect body rather than during render, so a ref target has already been populated by React — ref.current is null while rendering and only becomes a node in the commit phase. This is the difference between the listener attaching and silently doing nothing.
On the server it's a no-op. The export is isBrowser ? implementation : noop, so nothing touches window during SSR and there's no typeof window === "undefined" guard for you to write. Listeners attach after hydration, in the effect, like every other browser subscription.
Case 2 exists because document isn't available during module evaluation on the server, and because passing document directly would be a new-identity-every-render problem for anything looked up on the fly. The wrapper function is resolved at commit time and its result is what the effect keys on, so () => document is stable in the way that matters.
Case 4 is the one people forget: EventTarget is not just DOM elements. A Worker, a WebSocket, an EventSource, a MediaQueryList, window.visualViewport, a BroadcastChannel, an element, navigator.serviceWorker, even an AbortSignal — all of them are event targets, and all of them work here with the same automatic cleanup. (For the common ones, the library already ships purpose-built hooks: useEventSource, useBroadcastChannel, useMediaQuery, useNetwork.)
This is the part worth being precise about, because the hook ships six overloads and they don't all give you the same thing. Verified against the current types:
| Target form | e is inferred as | | --- | --- | | omitted (window) | the exact WindowEventMap type — "keydown" → KeyboardEvent ✅ | | a raw HTMLElement / Element / Document | the exact event type — "click" → MouseEvent ✅ | | a ref object | any ⚠️ | | a function target (() => document) | any ⚠️ |
The two any cases fall through to the general overload, which types the handler as (...p: any) => void. Nothing breaks — but you lose the autocomplete and the type checking exactly where refs are most common. The fix is one annotation, and it costs nothing:
Two related sharp edges in the same area. First, e is the native DOM event, not React's SyntheticEvent — e.target is not typed for you, e.currentTarget is EventTarget | null, and there's no pooling to worry about. Second, the event name is only constrained when the target is one of the typed overloads; with a ref or function target the name is a plain string, so a typo like "keydwon" compiles happily and attaches a listener that never fires. If a listener seems dead, check the spelling before you check anything else.
The canonical window listener. One hook per shortcut, or one handler with a switch — both are fine, because neither re-registers:
Note the composition: useEventListener is a fine primitive to build your hooks on, and because the handler is ref-held, run and combo can be inline closures over fresh state without any memoization ceremony. If you only need the modifier keys themselves, useKeyModifier already tracks them.
This is the case JSX props genuinely cannot do. React attaches onWheel and onTouchStart as passive listeners at the root, so calling e.preventDefault() inside them logs a console warning and does nothing. To actually block a scroll or a pinch you need a real listener registered with { passive: false } on the element:
