JavaScript Events
Event-driven programming is the backbone of interactive web applications. The browser continuously emits events — clicks, key presses, scrolls, form submissions, touch gestures — and your JavaScript code responds to them. Understanding the event system is essential for building responsive, performant interfaces.
At its core, an event is a signal that something has happened. The browser creates an Event object with information about the action — which element was clicked, which key was pressed, the mouse coordinates, or the touch points. Your code registers listeners (also called handlers) that execute when the event occurs.
The DOM Events specification defines three phases of event propagation: capturing, target, and bubbling. Understanding this flow is critical for debugging and controlling event behavior, especially in complex UIs with nested elements.
The browser supports dozens of event types organized by category. Each event type has a specific interface — mouse events include coordinates and button info, keyboard events include key codes and modifier states, and touch events include touch points and gestures.
| Category | Event Types | Interface |
|---|---|---|
| Mouse | click, dblclick, mousedown, mouseup, mousemove, mouseover, mouseout, mouseenter, mouseleave, contextmenu | MouseEvent |
| Keyboard | keydown, keyup, keypress (deprecated) | KeyboardEvent |
| Form | submit, reset, change, input, focus, blur, focusin, focusout, select, invalid | Event / FocusEvent |
| Focus | focus, blur, focusin, focusout | FocusEvent |
| Scroll / Resize | scroll, resize, wheel | Event / WheelEvent |
| Touch | touchstart, touchmove, touchend, touchcancel | TouchEvent |
| Window / Document | load, DOMContentLoaded, beforeunload, unload, hashchange, popstate, resize, online, offline | Event / HashChangeEvent / PopStateEvent |
| Clipboard | copy, cut, paste | ClipboardEvent |
| Drag & Drop | drag, dragstart, dragend, dragenter, dragover, dragleave, drop | DragEvent |
| Animation | animationstart, animationend, animationiteration, transitionstart, transitionend, transitioncancel | AnimationEvent / TransitionEvent |
| Media | play, pause, ended, timeupdate, volumechange, loadedmetadata, canplay, waiting, seeking, progress, error | Event |
| 1 | // Common event type examples |
| 2 | document.addEventListener('click', (e) => { |
| 3 | console.log('Clicked at:', e.clientX, e.clientY); |
| 4 | }); |
| 5 | |
| 6 | document.addEventListener('keydown', (e) => { |
| 7 | if (e.key === 'Escape') { |
| 8 | console.log('Modal dismissed via keyboard'); |
| 9 | } |
| 10 | }); |
| 11 | |
| 12 | window.addEventListener('resize', () => { |
| 13 | console.log('Viewport:', window.innerWidth, 'x', window.innerHeight); |
| 14 | }); |
| 15 | |
| 16 | element.addEventListener('touchstart', (e) => { |
| 17 | const touch = e.touches[0]; |
| 18 | console.log('Touch at:', touch.clientX, touch.clientY); |
| 19 | }); |
The addEventListener method is the modern, standards-compliant way to register event handlers. It replaces the older onclick / onchange attribute approach and the legacy attachEvent (Internet Explorer only). Unlike inline handlers, addEventListener allows multiple listeners for the same event on the same element and gives fine-grained control over capture phase, passive mode, and one-time execution.
| 1 | // Modern approach — addEventListener |
| 2 | const button = document.getElementById('submitBtn'); |
| 3 | |
| 4 | button.addEventListener('click', function handleClick(e) { |
| 5 | console.log('Button clicked'); |
| 6 | }); |
| 7 | |
| 8 | button.addEventListener('click', function anotherHandler(e) { |
| 9 | console.log('This also runs — multiple listeners'); |
| 10 | }); |
| 11 | |
| 12 | // Options object |
| 13 | button.addEventListener('click', handler, { |
| 14 | capture: false, // listen during bubbling phase (default) |
| 15 | once: true, // auto-remove after first invocation |
| 16 | passive: true, // signal that preventDefault will not be called |
| 17 | }); |
| 18 | |
| 19 | // Once option — fires once then removes itself |
| 20 | button.addEventListener('click', () => { |
| 21 | console.log('This runs only once'); |
| 22 | }, { once: true }); |
| 23 | |
| 24 | // Remove listener (requires named function reference) |
| 25 | button.removeEventListener('click', handleClick); |
The options object supports three boolean flags. capture switches to the capturing phase. once ensures the listener fires at most once and then removes itself — useful for initialization events. passive tells the browser the listener will never call preventDefault(), enabling scroll optimizations.
| Method | Multiple Listeners | Remove Listener | Capture Phase | Browser Support |
|---|---|---|---|---|
| addEventListener | ✓ | removeEventListener | ✓ (third arg) | All modern browsers |
| onclick attribute | ✗ (overwrites) | Set to null | ✗ | Legacy, avoid |
| attachEvent | ✓ | detachEvent | ✗ | IE only (obsolete) |
best practice
Every event handler receives an Event object as its first argument. This object contains properties and methods that describe the event and allow you to control its behavior. The specific interface depends on the event type — MouseEvent has clientX/clientY, KeyboardEvent has key/code, and TouchEvent has touches/changedTouches.
| Property / Method | Type | Description |
|---|---|---|
| target | Element | The element that dispatched the event (the innermost clicked element) |
| currentTarget | Element | The element the listener is attached to (may differ from target during bubbling) |
| type | string | Event type name: click, keydown, submit, etc. |
| eventPhase | number | 1 (capture), 2 (at target), 3 (bubbling) |
| bubbles | boolean | Whether the event propagates upward through the DOM |
| cancelable | boolean | Whether preventDefault is allowed |
| defaultPrevented | boolean | Whether preventDefault was called |
| timestamp | number | Time when the event was created (ms since page load) |
| preventDefault() | method | Cancels the default browser action (e.g., link navigation, form submission) |
| stopPropagation() | method | Stops further propagation (bubbling or capturing) |
| stopImmediatePropagation() | method | Stops propagation and prevents other listeners on the same element |
| 1 | // Event object properties in action |
| 2 | document.querySelector('a').addEventListener('click', (e) => { |
| 3 | console.log('Target:', e.target); // The <a> element |
| 4 | console.log('Current target:', e.currentTarget); // Same element (no bubbling here) |
| 5 | console.log('Type:', e.type); // "click" |
| 6 | console.log('Phase:', e.eventPhase); // 2 (at target) |
| 7 | console.log('Bubbles:', e.bubbles); // true |
| 8 | console.log('Cancelable:', e.cancelable); // true |
| 9 | console.log('Timestamp:', e.timeStamp); // 4823.4 |
| 10 | |
| 11 | e.preventDefault(); // Prevent navigation |
| 12 | console.log('Default prevented:', e.defaultPrevented); // true |
| 13 | }); |
| 14 | |
| 15 | // MouseEvent specific properties |
| 16 | document.addEventListener('click', (e) => { |
| 17 | const { clientX, clientY, screenX, screenY, altKey, ctrlKey, shiftKey, button } = e; |
| 18 | console.log({ clientX, clientY, screenX, screenY, altKey, ctrlKey, shiftKey, button }); |
| 19 | }); |
| 20 | |
| 21 | // KeyboardEvent specific properties |
| 22 | document.addEventListener('keydown', (e) => { |
| 23 | const { key, code, repeat, altKey, ctrlKey, shiftKey, metaKey } = e; |
| 24 | console.log({ key, code, repeat, altKey, ctrlKey, shiftKey, metaKey }); |
| 25 | if (key === 'Enter' && e.shiftKey) { |
| 26 | console.log('Shift+Enter pressed'); |
| 27 | } |
| 28 | }); |
When an event fires on a DOM element, it does not stay on that element. It travels through the DOM tree in three phases: capturing, target, and bubbling. Understanding propagation is essential for controlling event behavior and building reliable interaction patterns.
| 1 | // Propagation demonstration |
| 2 | <div id="outer" style="padding:20px;background:#1A1A2E"> |
| 3 | <div id="inner" style="padding:20px;background:#2A2A4E"> |
| 4 | <button id="btn">Click Me</button> |
| 5 | </div> |
| 6 | </div> |
| 7 | |
| 8 | <script> |
| 9 | // Capturing phase (third argument: true) |
| 10 | document.getElementById('outer').addEventListener('click', () => { |
| 11 | console.log('CAPTURE: outer'); |
| 12 | }, true); |
| 13 | |
| 14 | document.getElementById('inner').addEventListener('click', () => { |
| 15 | console.log('CAPTURE: inner'); |
| 16 | }, true); |
| 17 | |
| 18 | // Target phase |
| 19 | document.getElementById('btn').addEventListener('click', () => { |
| 20 | console.log('TARGET: button'); |
| 21 | }); |
| 22 | |
| 23 | // Bubbling phase (default) |
| 24 | document.getElementById('inner').addEventListener('click', () => { |
| 25 | console.log('BUBBLE: inner'); |
| 26 | }); |
| 27 | |
| 28 | document.getElementById('outer').addEventListener('click', () => { |
| 29 | console.log('BUBBLE: outer'); |
| 30 | }); |
| 31 | |
| 32 | // Click on button logs: |
| 33 | // CAPTURE: outer |
| 34 | // CAPTURE: inner |
| 35 | // TARGET: button |
| 36 | // BUBBLE: inner |
| 37 | // BUBBLE: outer |
| 38 | </script> |
| 1 | // Stopping propagation |
| 2 | document.getElementById('inner').addEventListener('click', (e) => { |
| 3 | e.stopPropagation(); |
| 4 | // Event will NOT reach outer during bubbling |
| 5 | // Does NOT prevent other listeners on inner |
| 6 | }); |
| 7 | |
| 8 | document.getElementById('btn').addEventListener('click', (e) => { |
| 9 | e.stopImmediatePropagation(); |
| 10 | // Stops ALL propagation AND other listeners on this element |
| 11 | }); |
warning
Event delegation leverages event bubbling to handle events efficiently. Instead of attaching a listener to every child element, you attach a single listener to a parent element and use e.target to determine which child was interacted with. This is especially powerful for dynamic lists, tables, and any collection of similar elements.
| Aspect | Individual Listeners | Event Delegation |
|---|---|---|
| Memory | One listener per element | Single listener on parent |
| Dynamic elements | Must re-attach after DOM changes | Works automatically |
| Performance | Degrades with many elements | Constant — O(1) |
| Target filtering | Automatic per-element | Requires manual checks |
| When to use | Few static elements, unique handlers | Lists, tables, dynamic content |
| 1 | // BAD — individual listeners (don't do this for many elements) |
| 2 | document.querySelectorAll('.list-item').forEach(item => { |
| 3 | item.addEventListener('click', () => { |
| 4 | console.log('Clicked:', item.textContent); |
| 5 | }); |
| 6 | }); |
| 7 | |
| 8 | // GOOD — event delegation |
| 9 | document.querySelector('.list-container').addEventListener('click', (e) => { |
| 10 | // Find the closest list item |
| 11 | const item = e.target.closest('.list-item'); |
| 12 | if (!item) return; // Ignore clicks outside items |
| 13 | |
| 14 | console.log('Clicked:', item.textContent); |
| 15 | console.log('Data attr:', item.dataset.id); |
| 16 | }); |
| 17 | |
| 18 | // Pattern for filtering by data attribute |
| 19 | document.querySelector('.toolbar').addEventListener('click', (e) => { |
| 20 | const button = e.target.closest('[data-action]'); |
| 21 | if (!button) return; |
| 22 | |
| 23 | const action = button.dataset.action; |
| 24 | switch (action) { |
| 25 | case 'save': saveDocument(); break; |
| 26 | case 'delete': deleteDocument(); break; |
| 27 | case 'export': exportDocument(); break; |
| 28 | } |
| 29 | }); |
| 1 | // Delegation with dynamic elements |
| 2 | <ul id="todoList"> |
| 3 | <li data-id="1" class="todo-item">Learn JavaScript <button class="delete">×</button></li> |
| 4 | <li data-id="2" class="todo-item">Build a project <button class="delete">×</button></li> |
| 5 | </ul> |
| 6 | <button id="addTodo">Add Todo</button> |
| 7 | |
| 8 | <script> |
| 9 | const list = document.getElementById('todoList'); |
| 10 | |
| 11 | // Single delegation listener handles ALL items including future ones |
| 12 | list.addEventListener('click', (e) => { |
| 13 | // Delete button click |
| 14 | if (e.target.classList.contains('delete')) { |
| 15 | const item = e.target.closest('.todo-item'); |
| 16 | item.remove(); |
| 17 | console.log('Deleted item:', item.dataset.id); |
| 18 | return; |
| 19 | } |
| 20 | |
| 21 | // Item click (but not delete button) |
| 22 | if (e.target.classList.contains('todo-item')) { |
| 23 | e.target.classList.toggle('completed'); |
| 24 | } |
| 25 | }); |
| 26 | |
| 27 | // New items added dynamically still work with delegation |
| 28 | document.getElementById('addTodo').addEventListener('click', () => { |
| 29 | const li = document.createElement('li'); |
| 30 | li.className = 'todo-item'; |
| 31 | li.dataset.id = Date.now(); |
| 32 | li.innerHTML = 'New task <button class="delete">×</button>'; |
| 33 | list.appendChild(li); |
| 34 | }); |
| 35 | </script> |
pro tip
The browser is not the only source of events. You can create and dispatch your own custom events using the CustomEvent constructor. This enables a pub/sub communication pattern between decoupled components — one part of your application can signal state changes and other parts can listen without direct references.
| 1 | // Creating and dispatching a custom event |
| 2 | const event = new CustomEvent('userLogin', { |
| 3 | detail: { |
| 4 | userId: 42, |
| 5 | username: 'johndoe', |
| 6 | role: 'admin', |
| 7 | timestamp: Date.now() |
| 8 | }, |
| 9 | bubbles: true, |
| 10 | cancelable: true |
| 11 | }); |
| 12 | |
| 13 | // Dispatch on any element |
| 14 | document.getElementById('app').dispatchEvent(event); |
| 15 | |
| 16 | // Listen for the custom event |
| 17 | document.addEventListener('userLogin', (e) => { |
| 18 | const { userId, username, role } = e.detail; |
| 19 | console.log(`User ${username} (${role}) logged in`); |
| 20 | updateUI(userId); |
| 21 | }); |
| 22 | |
| 23 | // Event-based component communication |
| 24 | function showNotification(message, type = 'info') { |
| 25 | const event = new CustomEvent('notification', { |
| 26 | detail: { message, type }, |
| 27 | bubbles: true |
| 28 | }); |
| 29 | document.dispatchEvent(event); |
| 30 | } |
| 31 | |
| 32 | document.addEventListener('notification', (e) => { |
| 33 | const { message, type } = e.detail; |
| 34 | renderToast(message, type); |
| 35 | }); |
| 36 | |
| 37 | // Using events for state management |
| 38 | class Store extends EventTarget { |
| 39 | constructor(initial = {}) { |
| 40 | super(); |
| 41 | this.state = initial; |
| 42 | } |
| 43 | |
| 44 | setState(update) { |
| 45 | const prev = { ...this.state }; |
| 46 | this.state = { ...this.state, ...update }; |
| 47 | this.dispatchEvent(new CustomEvent('stateChange', { |
| 48 | detail: { state: this.state, prev } |
| 49 | })); |
| 50 | } |
| 51 | |
| 52 | getState() { |
| 53 | return this.state; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | const store = new Store({ count: 0 }); |
| 58 | store.addEventListener('stateChange', (e) => { |
| 59 | console.log('State changed:', e.detail.state); |
| 60 | }); |
| 61 | store.setState({ count: 1 }); |
info
Some events fire at high frequency — scroll, resize, mousemove, and input events can fire hundreds of times per second. Running expensive computations on every event causes jank, battery drain, and poor UX. Throttling and debouncing limit how often a function executes, but they work differently.
| Strategy | Behavior | Best For |
|---|---|---|
| Throttle | Executes at most once per interval, ensures regular execution | Scroll handlers, resize, progress tracking |
| Debounce | Executes after a period of inactivity, resets on each call | Search input, autocomplete, save drafts, resize end |
| 1 | // Throttle implementation — runs at most once per interval |
| 2 | function throttle(fn, delay = 100) { |
| 3 | let lastCall = 0; |
| 4 | let timer = null; |
| 5 | |
| 6 | return function(...args) { |
| 7 | const now = Date.now(); |
| 8 | const remaining = delay - (now - lastCall); |
| 9 | |
| 10 | if (remaining <= 0) { |
| 11 | // Enough time has passed — execute immediately |
| 12 | if (timer) { |
| 13 | clearTimeout(timer); |
| 14 | timer = null; |
| 15 | } |
| 16 | lastCall = now; |
| 17 | fn.apply(this, args); |
| 18 | } else if (!timer) { |
| 19 | // Schedule execution at the end of remaining time |
| 20 | timer = setTimeout(() => { |
| 21 | lastCall = Date.now(); |
| 22 | timer = null; |
| 23 | fn.apply(this, args); |
| 24 | }, remaining); |
| 25 | } |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | // Usage |
| 30 | window.addEventListener('scroll', throttle(() => { |
| 31 | console.log('Scroll position:', window.scrollY); |
| 32 | updateProgressBar(); |
| 33 | }, 100)); |
| 34 | |
| 35 | // Debounce implementation — runs after inactivity |
| 36 | function debounce(fn, delay = 300) { |
| 37 | let timer = null; |
| 38 | |
| 39 | return function(...args) { |
| 40 | clearTimeout(timer); |
| 41 | timer = setTimeout(() => { |
| 42 | timer = null; |
| 43 | fn.apply(this, args); |
| 44 | }, delay); |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | // Usage |
| 49 | searchInput.addEventListener('input', debounce((e) => { |
| 50 | const query = e.target.value; |
| 51 | console.log('Searching:', query); |
| 52 | fetchSearchResults(query); |
| 53 | }, 400)); |
| 54 | |
| 55 | // Leading edge debounce — runs immediately, then waits |
| 56 | function debounceLeading(fn, delay = 300) { |
| 57 | let timer = null; |
| 58 | let leading = true; |
| 59 | |
| 60 | return function(...args) { |
| 61 | if (leading) { |
| 62 | leading = false; |
| 63 | fn.apply(this, args); |
| 64 | } |
| 65 | clearTimeout(timer); |
| 66 | timer = setTimeout(() => { |
| 67 | leading = true; |
| 68 | }, delay); |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | // Immediate save on first change, then debounce subsequent |
| 73 | const saveHandler = debounceLeading(() => { |
| 74 | saveDocument(); |
| 75 | }, 2000); |
best practice
| 1 | // Cleanup with AbortController (modern pattern) |
| 2 | const controller = new AbortController(); |
| 3 | const { signal } = controller; |
| 4 | |
| 5 | window.addEventListener('resize', handleResize, { signal }); |
| 6 | document.addEventListener('click', handleClick, { signal }); |
| 7 | element.addEventListener('scroll', handleScroll, { passive: true, signal }); |
| 8 | |
| 9 | // Later — remove ALL listeners at once |
| 10 | controller.abort(); |
| 11 | |
| 12 | // React-style cleanup pattern |
| 13 | function setupEventListeners(element) { |
| 14 | const ctrl = new AbortController(); |
| 15 | |
| 16 | element.addEventListener('click', handler, { signal: ctrl.signal }); |
| 17 | window.addEventListener('resize', resizeHandler, { signal: ctrl.signal }); |
| 18 | |
| 19 | // Return cleanup function |
| 20 | return () => ctrl.abort(); |
| 21 | } |
| 22 | |
| 23 | const cleanup = setupEventListeners(myElement); |
| 24 | // Later: |
| 25 | cleanup(); // Removes both listeners |
| 26 | |
| 27 | // Keyboard and mouse together |
| 28 | button.addEventListener('keydown', (e) => { |
| 29 | if (e.key === 'Enter' || e.key === ' ') { |
| 30 | e.preventDefault(); |
| 31 | activate(); |
| 32 | } |
| 33 | }); |
| 34 | |
| 35 | button.addEventListener('click', activate); |
danger