JavaScript Event Listeners Table

PieceWhat it doesField note
addEventListener(type, fn)Subscribe to an eventMultiple listeners stack; on-property assignment overwrites (the one-slot bug)
{ once: true }Auto-remove after first fireInline setup logic without the manual removeEventListener dance
{ passive: true }Promise not to preventDefaultScroll listeners MUST be passive - the jank fix browsers had to force
{ signal }AbortController removalOne controller aborts many listeners - the SPA teardown pattern
e.preventDefault()Stop the DEFAULT actionBlocks the browser behavior; the event keeps bubbling
e.stopPropagation()Stop the JOURNEYHalts other listeners; use sparingly - it breaks delegation downstream
bubbling vs capturingInner-first vs outer-firstDefault is bubble; capture: true hears it on the way down
delegationOne listener on the parentevent.target identifies the child - survives rerenders, one listener for 1000 rows
Reference: the MDN addEventListener reference. The options object is the modern half of the API: once removes the setup boilerplate, passive unlocks smooth scrolling (a non-passive touch listener forces the browser to WAIT for your handler before scrolling - the jank browsers now default away from), and signal wires removal into an AbortController for whole-component teardown. Bottom line: preventDefault cancels the browser's plan, stopPropagation cancels the event's journey - different axes, and delegation (one parent listener reading event.target) beats per-child listeners for anything that rerenders. Related tools: DOM manipulation table (why delegation survives replaceChildren), event loop table (when handlers actually run), and closure table (what every handler captures - and leaks).

addEventListener has two halves: the signature everyone knows (type, handler) and the options object everyone should - once for fire-once setup, passive for scroll performance, signal for AbortController-driven teardown. The on-property style (onclick = fn) still works but holds exactly ONE handler slot.

Bottom line: preventDefault cancels the browser's plan (the form submit, the link navigation) while the event keeps traveling; stopPropagation cancels the event's journey while default actions proceed. Different axes - and the one you almost never need is stopPropagation, because it silently breaks delegated listeners living upstream.

The honest part: passive listeners exist because of a performance hostage situation. A non-passive touch/wheel listener forces the browser to WAIT for your handler before it can start scrolling (you might call preventDefault), which turned every stray scroll handler into a jank source - so browsers now default certain document-level listeners to passive, and your explicit choice should match.

How to use

  1. Replace onclick assignments with addEventListener - stacking multiple listeners on one element is the default behavior you want, not an accident.
  2. Use { once: true } for init logic and welcome mats - the browser removes the listener after the first fire, no manual removal code.
  3. Tear down with a signal: const c = new AbortController(); addEventListener('resize', fn, { signal: c.signal }) - c.abort() removes every listener it wired.

Frequently asked questions

When exactly should I pass { passive: true }?

On every scroll-adjacent listener that never calls preventDefault: wheel, touchstart, touchmove, scroll. The browser must otherwise pause scrolling until your handler finishes, in case you block the default - a wait that reads as jank on exactly the gestures users notice most. Chrome and Firefox already treat document-level wheel/touch listeners as passive by default, but element-level listeners stay non-passive, so an explicit passive: true on a container's touchmove is still the difference between smooth and stuttering on mid-range phones. If your handler DOES need preventDefault (a custom pull-to-refresh), keep it non-passive and scope it narrowly.

What is the real difference between preventDefault and stopPropagation?

Two different cancellations. preventDefault cancels the browser's DEFAULT reaction - the link stops navigating, the checkbox stays unchecked, the form stays put - but the event continues its journey, so ancestor listeners still run. stopPropagation cancels the JOURNEY - other listeners (including the browser's internal defaults wired to ancestors) never see the event, but any default action tied directly to the element still happens unless you also preventDefault. In practice: forms and links want preventDefault; stopPropagation is for isolating widgets inside clickable parents - and if you reach for it to fix a delegation bug, the design probably wants stopImmediatePropagation's problem solved differently.

How does event delegation actually work and when does it beat direct binding?

One listener on a stable ancestor reads event.target to identify which descendant was interacted with. It beats direct binding in three cases: many children (one listener for 1,000 rows instead of 1,000), children that come and go (replaceChildren rebuilds content but the parent listener survives), and uniform behavior (a list where every item opens the same handler). The cost: e.target may be a nested element inside the row, so climb with e.target.closest('li') - and once you use stopPropagation anywhere in a child, delegation upstream breaks, which is the main way delegation designs fail.

Why did my listener stop firing after a rerender?

Listeners attach to NODES, and the rerender replaced the nodes - the new elements have no listeners because binding is not inherited by markup. The fixes in preference order: delegate from a parent that survives the rerender (the structural fix), rebind after each render (works, leaks unless you remove old listeners), or use the AbortController signal pattern so rebinding and cleanup are one abort call. This is also why frameworks took over event wiring - their synthetic event systems attach at the root once and route by component tree, making rerender-proof binding the default instead of a discipline.

Related tools