JavaScript Event Listeners Table
| Piece | What it does | Field note |
|---|---|---|
addEventListener(type, fn) | Subscribe to an event | Multiple listeners stack; on-property assignment overwrites (the one-slot bug) |
{ once: true } | Auto-remove after first fire | Inline setup logic without the manual removeEventListener dance |
{ passive: true } | Promise not to preventDefault | Scroll listeners MUST be passive - the jank fix browsers had to force |
{ signal } | AbortController removal | One controller aborts many listeners - the SPA teardown pattern |
e.preventDefault() | Stop the DEFAULT action | Blocks the browser behavior; the event keeps bubbling |
e.stopPropagation() | Stop the JOURNEY | Halts other listeners; use sparingly - it breaks delegation downstream |
bubbling vs capturing | Inner-first vs outer-first | Default is bubble; capture: true hears it on the way down |
delegation | One listener on the parent | event.target identifies the child - survives rerenders, one listener for 1000 rows |
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
- Replace onclick assignments with addEventListener - stacking multiple listeners on one element is the default behavior you want, not an accident.
- Use { once: true } for init logic and welcome mats - the browser removes the listener after the first fire, no manual removal code.
- 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.