pyreon

Event listeners

The pattern

Register DOM event listeners via useEventListener from @pyreon/hooks. It handles cleanup on unmount, SSR safety, and listener re-binding when the target changes:

import { useEventListener } from '@pyreon/hooks'

function KeyboardShortcuts() {
  useEventListener(window, 'keydown', (e) => {
    if (e.key === 'Escape') closeModal()
  })

  return null
}

Targets can be:

  • window — page-level keybinds, resize/scroll, online/offline

  • document — delegation, focus management

  • An element signal / ref — useEventListener(() => buttonEl, 'click', handler)

  • A ref callback — wire via ref={(el) => buttonEl = el} then pass () => buttonEl

The listener runs on mount, is removed on unmount, and is rebound if the target signal changes.

Why

Raw addEventListener in a component body has three failure modes:

  1. No cleanup — the listener leaks on unmount, fires after the component is gone.

  2. SSR crashwindow is undefined on the server; the component render fails the whole page.

  3. Stale closures — the handler captures signal values at setup time, not at call time, unless you read inside the handler body.

useEventListener solves all three at once. Don't reach for the raw API inside component code.

Anti-pattern

// BROKEN — leaks on unmount, crashes on SSR
function KeyboardShortcuts() {
  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') closeModal()
  })
  return null
}
// LESS BROKEN — has cleanup, but duplicates what useEventListener does
function KeyboardShortcuts() {
  onMount(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape') closeModal()
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
  return null
}

The onMount version is technically correct. It just reimplements what useEventListener does in 3 lines. Reach for the hook first.

Exceptions

Framework-host chains like view.dom.ownerDocument.addEventListener(...) in CodeMirror plugins are intentional and safe — the host view's own document is accessed through a scoped path, not through a bare document global. The raw-add-event-listener detector recognises these cases and does not flag them.

  • Detector: raw-add-event-listener / raw-remove-event-listener — the MCP validate tool flags raw listener registrations

  • Reference API: useEventListener in @pyreon/hooks — see get_api({ package: "hooks", symbol: "useEventListener" })

  • Anti-pattern: "Raw addEventListener / removeEventListener in component or hook bodies"

Event listeners