CSE 134B Web Client Languages
  1. Interaction
  2. APIs
  3. Events

An APIs series

Events

Nothing in a browser runs because you asked nicely. Code runs because something happened — a click, a keypress, a form submission, a timer firing, a page finishing its load — and you arranged to be told about it. That arrangement is the event model, and it is the single piece of machinery every interactive page sits on. This series covers it before the DOM series on purpose: you can use events without ever touching the tree, but you cannot demonstrate the tree without events to drive the demos.

Every step of the series on one page. Use the step pages to work through it a step at a time.

Step 01

Hello, events

An event is the browser telling you something happened. A handler is the function you asked it to call when it does. That is the whole model — but there are three different ways to make the arrangement, all three still work, and you will meet all three in code in the wild. This step is about telling them apart and understanding why modern code uses exactly one of them.

Learning Objectives

  • Bind a handler three ways: inline attribute, element property, and addEventListener
  • Explain why inline handlers mix content with behavior, and why that costs you
  • Stack multiple listeners on one element and predict their firing order
  • Use listener options: once, and removing a handler you added

Three ways to say “call me”

Event binding has accumulated in layers, and each layer is still supported. Oldest first:

1. The inline attribute

<button onclick="doSomething()">Click</button>

JavaScript inside your markup. It works, and it is how everyone did it in 1997 — but it puts behavior in the content layer, binds you to global function names, and gives you exactly one handler per attribute. The same reasoning that moved styling out of <font> tags and into CSS moves behavior out of onclick attributes and into script.

2. The element property

const btn = document.querySelector('#saveBtn'); btn.onclick = doSomething; // note: the function itself, not a call

Behavior is in the script layer now, which is real progress. But a property holds one value: assign a second handler and the first is silently gone. Two pieces of code that both want to hear about clicks on the same element cannot share a property.

3. addEventListener

btn.addEventListener('click', doSomething); btn.addEventListener('click', alsoDoThis); // both fire, in order added btn.addEventListener('click', justOnce, { once: true }); btn.removeEventListener('click', doSomething); // needs the same reference

Many listeners per element, per event type, firing in the order added. Options like once for self-removing handlers, and clean removal — provided you kept a reference to the function, which is why named functions beat anonymous ones for any handler you might need to detach.

Live demo

The demo below is a survey of the whole model. Sections 1 and 2 are this step: compare property binding against addEventListener, watch a once handler expire, and stack three handlers on one button. Sections 3–6 — the event object, propagation, delegation, passive listeners — are previews of the steps ahead; press things now, and come back when each topic gets its own step.

Notice the pattern every demo in this series uses: no inline handlers anywhere, one small $() helper for selection, and results written into an <output> element with aria-live so the result is announced, not just painted.

Why this is the fault line

The three bindings are not three equal styles; they mark the boundary between page-as-document and page-as-program. Inline handlers scatter program logic through the markup where it cannot be tested, reused, or removed. addEventListener keeps the contract in one place and makes the page's behavior something you can reason about — and, in step 4, something you can centralize down to a single listener for an entire list. Every demo from here forward assumes it.

Next Steps

In Step 2: The event object, we look at what your handler is actually handed when the call comes:

  • Read type, target, coordinates, keys, and modifiers
  • Cancel default behavior with preventDefault()
  • Build an event watcher you can point at any element

Step 02

The event object

When the browser calls your handler, it does not call it empty-handed. It passes one argument: an event object describing exactly what happened — what kind of event, where, on which element, with which keys held down, at what time. Most event-driven code is just reading this object and deciding what to do about it, so the fastest way to get fluent is to build an instrument that shows it to you.

Learning Objectives

  • Read the core properties: type, target, currentTarget, timeStamp
  • Use pointer data (clientX/clientY, button) and key data (key, code, modifier flags)
  • Cancel an event's default action with preventDefault()
  • Build a start/stop event watcher from paired addEventListener / removeEventListener calls

One object, many shapes

Every event carries the basics — type, target, timeStamp — and then a layer of properties specific to its kind. A keydown has key and code; a mouse event has coordinates and a button number; both carry the modifier flags altKey, ctrlKey, shiftKey. The demo below dumps one fixed list of properties for every event it sees:

const attrs = [ 'type','timeStamp','target','currentTarget','clientX','clientY', 'key','code','altKey','ctrlKey','shiftKey','button' ]; const fmt = (e) => { const lines = [`EVENT @ ${Math.round(e.timeStamp)}ms`]; for (const k of attrs) lines.push(`${k}: ${e[k]}`); return lines.join('\n'); };

Ask a keyboard event for clientX and you get undefined — not every property is meaningful for every event, and the watcher makes that visible rather than hiding it. For open-ended exploration, console.dir(e) inside any handler shows you everything an event actually carries.

Live demo

Press Start watching, then generate events: type in the textarea (try holding Alt, Shift, or Ctrl), move the mouse through the tracking area, click the button. Each event overwrites the property log on the left. Stop watching detaches every listener — the page goes quiet, which is its own lesson.

The start/stop pair works because the handlers are kept in one object, so the same function references get attached and detached:

const handlers = { keydown: (e) => out.textContent = fmt(e), mousemove: (e) => out.textContent = fmt(e), click: (e) => out.textContent = fmt(e), }; function start() { txt.addEventListener('keydown', handlers.keydown); area.addEventListener('mousemove', handlers.mousemove); btn.addEventListener('click', handlers.click); } function stop() { txt.removeEventListener('keydown', handlers.keydown); area.removeEventListener('mousemove', handlers.mousemove); btn.removeEventListener('click', handlers.click); }

Canceling the default

Many events announce something the browser is about to do: a click on a link navigates, a submit posts the form, a keydown types a character. preventDefault() is your veto. The handler still runs, the event still travels the tree — only the built-in consequence is canceled. You saw it in step 1 on a link; it returns in step 5 as the heart of form handling, where canceling the submit is what makes client-side processing possible.

Next Steps

In Step 3: Event flow, we follow one event's whole journey through the tree:

  • Trace the three phases: capture, target, bubble
  • Read eventPhase to see where a listener sits in the journey
  • Stop the journey with stopPropagation() — and see what it does not stop

Step 03

Event flow

A click on a button is not delivered to the button alone. The event travels: down from the document to the target, and back up again. Every element on that path gets a chance to hear it. This sounds like trivia until you realize it is the mechanism that lets one listener serve an entire subtree — but first, the journey itself.

Learning Objectives

  • Name the three phases: capture (document → target), target, bubble (target → document)
  • Choose the phase a listener fires in with addEventListener's third argument
  • Read event.eventPhase to see which leg of the journey a listener caught
  • Halt further travel with stopPropagation() — and distinguish it from preventDefault()

Down, land, up

When an event fires on a nested element, the browser first walks the tree downward from the document to the target — the capture phase — then arrives at the target itself, then walks back up to the document — the bubble phase. Listeners default to the bubble leg; passing true (or { capture: true }) as the third argument moves them to the capture leg:

// cap is true or false — same handler, opposite leg of the journey outer.addEventListener('click', (e) => log('outer', e), cap); middle.addEventListener('click', (e) => log('middle', e), cap); inner.addEventListener('click', (e) => { log('inner', e); if (stopInner.checked) e.stopPropagation(); }, cap);

Inside the handler, eventPhase reports which leg this listener caught: 1 for capture, 2 at the target, 3 for bubble. The demo's log line is exactly that check:

function log(where, e) { append(`${where} (${e.eventPhase === 1 ? 'capture' : e.eventPhase === 2 ? 'target' : 'bubble'})`); }

Why does app code default to bubbling? Because you usually care that the thing itself was clicked, and want ancestors informed afterward. Capture runs ancestors first, before the target has said a word — useful for global concerns like analytics or dismissing popups, and best used sparingly.

Live demo

Three nested boxes — outer, middle, inner — each with a click listener. Click the inner box and read the log: in bubble mode the order is inner → middle → outer; switch to capture and it reverses. Check inner.stopPropagation() and watch the journey end early — but note which direction it ends in each mode. The inner box is a real control (role="button", tabindex="0"), so Enter and Space click it from the keyboard too.

One implementation detail worth stealing: to switch phases the demo must shed its old listeners, and rather than bookkeeping every handler reference it clones each box and replaces it — el.replaceWith(el.cloneNode(true)) — because a clone carries markup but not listeners. A blunt tool, but an honest demonstration of where listeners actually live.

What stopPropagation does not do

Reach for stopPropagation() rarely. Code that silences events breaks any ancestor legitimately listening for them — including the delegation pattern you are about to build.

Next Steps

Bubbling is not trivia — it is infrastructure. Because every click on every child passes through the parent, the parent can handle all of them. In Step 4: Event delegation:

  • Put one listener on a container instead of one per child
  • Identify the real target with e.target.closest()
  • Dispatch actions from data-action attributes

Step 04

Event delegation

Here is the problem the last step set up: a list where items come and go. Give every item's buttons their own listeners and you are signing up for bookkeeping — attach on create, detach on remove, and re-attach for anything added later. Miss one and you have a dead button or a leak. Delegation dissolves the whole problem: put one listener on the container, and let bubbling deliver every child's events to it — including children that do not exist yet.

Learning Objectives

  • Attach a single delegated listener to a stable parent
  • Recover the clicked control with e.target.closest() and matches()
  • Route behavior through data-action attributes instead of per-button handlers
  • Explain why delegation wins on memory and correctness for dynamic content

The pattern

Three moves. Listen on the parent; find out which control the click came from; act on its data-action. This is the entire click-handling code for the demo's list — add, done, and remove, for any number of items, present or future:

// One listener handles all buttons now and in the future list.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; // click wasn't on a button const li = btn.closest('.item'); const action = btn.dataset.action; if (action === 'done') { li.classList.toggle('done'); } if (action === 'remove') { li.remove(); } });

This is where step 2's distinction earns its keep: e.target is the button actually clicked, while the listener runs on the list — currentTarget. closest() bridges the gap, walking up from the target to the nearest matching ancestor, and returning null when the click landed on none — which is the early-return guard. The buttons themselves carry no behavior at all, just a label:

<button type="button" data-action="done" class="btn-primary">Done</button> <button type="button" data-action="remove">Remove</button>

Live demo

Left: the delegated list. Add items, mark them done, remove them — then look at the source and confirm there is exactly one click listener for the whole list. Right: the cost argument made concrete. Add 500 buttons creates five hundred live controls; the listener count stays at one.

// Performance demo: still just one listener on #container container.addEventListener('click', (e) => { if (e.target.matches('button')) perfOut.textContent = 'Clicked ' + e.target.textContent; });

Why this is the professional pattern

Per-child listeners cost memory in proportion to your content, and — worse — they couple correctness to your bookkeeping: every code path that creates an item must remember to wire it. Delegation costs one listener regardless of size, and new children are handled by construction, not by discipline. It is also not a niche trick: frameworks' event systems (React's synthetic events among them) have historically been delegation under the hood — listeners at the root, events routed to components by target. When you write it by hand you are writing the same machinery, minus the framework.

Next Steps

In Step 5: Forms: events that do work, events stop being demonstrations and start earning a living:

  • Intercept submit and take over from the browser
  • React to input and change as the user types
  • Read a whole form at once with FormData

Step 05

Forms: events that do work

Everything so far has been events for their own sake — logs and counters proving the model works. Forms are where events start earning a paycheck. A form is the platform's original interactive component: it collects values, validates them, and submits them, all before you write a line of script. Your script's job is to listen at the right moment, read what the user entered, and decide what happens next.

Learning Objectives

  • Handle submit on the form — not click on the button — and know why
  • Cancel navigation with preventDefault() and keep the page
  • Read fields by hand: value, checked radios, and selects
  • Read a whole form at once with new FormData(form)
  • Lean on constraint validation: checkValidity(), reportValidity()

Listen for submit, not click

A form submits in more ways than one: clicking the submit button, yes, but also pressing Enter in a text field. Listen for click on the button and you miss the keyboard path entirely. The submit event fires on the form for every path, which makes it the one true place to intervene:

form.addEventListener('submit', (e) => { e.preventDefault(); // stay on this page; we handle it if (!form.checkValidity()) { form.reportValidity(); // browser shows the messages out.textContent = 'Please fix the errors above.'; return; } // read the values, do the work... });

preventDefault() here cancels the browser's default action — packaging the fields and navigating to the form's action URL. That default is the right behavior for a classic server-rendered form; cancel it only because you are taking over the job.

Reading fields by hand

The manual approach reads each control through the DOM. Text inputs and textareas hand over value; a radio group needs a query for whichever one is :checked; a select's value is the chosen option:

const username = document.getElementById('username').value; const contact = document.querySelector('input[name="contact"]:checked')?.value || 'Not selected'; const country = document.getElementById('country').value;

The ?. matters: an untouched radio group has no checked member, and querying for one returns null. Note also what the demo does not do — echo the password back. It masks it before display. Small habit, right instinct.

Or let FormData do it

Field-by-field reading scales badly — every new control means another line of script. FormData reads the entire form in one constructor call, keyed by each control's name, exactly the way a server would receive it:

const formData = new FormData(form); for (const [key, val] of formData.entries()) { if (val instanceof File) { lines.push(`${key}: [File] name="${val.name}", size=${val.size} bytes`); } else { lines.push(`${key}: ${val}`); } }

File inputs contribute real File objects, unchecked checkboxes contribute nothing (just like a real submission), and when the network enters the story, the same object goes straight out the door: fetch(url, { method: 'POST', body: formData }).

Applied

Two small, real uses of the same machinery. First, the classic intro-course exercise done the platform way: read a number, convert on click, announce the result through a live region — and guard the empty field, because Number('') is 0, not an error.

Second, autocomplete without a library: a <datalist> gives the input native suggestions, and one input listener echoes the value as it changes. Before reaching for an autocomplete widget, check whether the platform already ships one.

Next Steps

So far you have only listened to events the browser invented. In Step 6: Custom events, you mint your own:

  • Create events with new CustomEvent(type, { detail })
  • Namespace your event names and route them through one listener
  • Build components that announce themselves instead of being polled

Step 06

Custom events

Every event so far was the browser's idea: it decided a click is a thing, a submit is a thing. Here is the part that elevates the event model from a UI convenience to an architecture: the same machinery is open to you. You can define your own event types, attach data to them, dispatch them, and listen for them — which means the pattern you have been learning all series doubles as your application's internal messaging system.

Learning Objectives

  • Create events with new CustomEvent(type, { detail }) and fire them with dispatchEvent
  • Namespace event names (timer:start) to keep them out of the browser's way
  • Route several related events through one listener function
  • Recognize the publish/subscribe pattern — components that announce instead of being polled

Mint, dispatch, listen

Three moving parts. A CustomEvent carries a type name you choose, plus an optional detail payload — any data the listener should receive. dispatchEvent fires it at an element. And addEventListener works on your type names exactly as it does on 'click':

// dispatch: a button announces what happened, with a payload startBtn.addEventListener('click', () => hub.dispatchEvent(new CustomEvent('timer:start', { detail: { at: Date.now() } }))); // listen: same API as any built-in event hub.addEventListener('timer:start', route); hub.addEventListener('timer:pause', route); hub.addEventListener('timer:stop', route);

The timer: prefix is a convention, not a requirement — but a good one. Namespaced names cannot collide with a current or future browser event, and they read as a vocabulary: everything the timer can say starts with timer:.

One router, one place for state

Three events could mean three separate handlers, each poking at the UI. The demo routes all three through one function instead, and that function is the single place where status text and button-state choreography live:

function route(e) { if (e.type === 'timer:start') { set('Timer Started'); enable('#startBtn', false); enable('#pauseBtn', true); enable('#stopBtn', true); } if (e.type === 'timer:pause') { set('Timer Paused'); enable('#startBtn', true); enable('#pauseBtn', false); enable('#stopBtn', true); } if (e.type === 'timer:stop') { set('Timer Stopped'); enable('#startBtn', true); enable('#pauseBtn', false); enable('#stopBtn', false); } if (e.detail) console.log('detail:', e.detail); }

Read the whole file and notice the shape: buttons announce (“start was requested, at this timestamp”), the router reacts. The button code knows nothing about which controls get disabled; the router knows nothing about which click produced the event. That separation is the entire point.

Open the console while you press the buttons — each event's detail payload is logged there.

Why this pattern matters

The alternative to announcing is polling — some other piece of code checking, on a timer or on every render, whether the timer's state changed. Events invert that: interested parties subscribe, and are called exactly when there is news. You have seen this pattern from the consuming side all series; now you own both ends.

Next Steps

Custom events fire when you say so. Some built-in events fire far more often than you can afford to handle. In Step 7: Taming the storm:

  • Meet the firehose events: scroll, pointermove, resize, rapid input
  • Throttle a handler to at most once per interval
  • Debounce a handler to run only after the input goes quiet

Step 07

Taming the storm

Most events are polite: a click here, a submit there. A few are a firehose. scroll and pointermove can fire on every frame; resize streams continuously while a window edge is dragged; input fires per keystroke. Your handler runs every single time — and if it does real work, the page pays for that work sixty times a second. The fix is not a faster handler. It is running the handler less often, on purpose.

Learning Objectives

  • Identify the high-frequency events and what they cost
  • Throttle: run at most once per interval, while activity continues
  • Debounce: run once, after activity stops
  • Choose between them by asking what the handler is for
  • Mark scroll and touch listeners passive

Two small functions

Both tools are wrappers: give them your handler and a wait time, get back a rate-limited version to hand to addEventListener. These are the demo's actual implementations — a dozen lines, no library:

function throttle(fn, wait) { let last = 0, t; return (...args) => { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(null, args); } else { clearTimeout(t); t = setTimeout(() => { last = Date.now(); fn.apply(null, args); }, wait - (now - last)); } }; } function debounce(fn, wait) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, a), wait); }; }

Throttle lets the handler through at most once per wait milliseconds — a steady drumbeat while events keep arriving. Debounce resets a timer on every event and fires only when the events stop for wait milliseconds — silence, then one call.

Watch the counters diverge

The demo wires the same events to normal, throttled, and debounced handlers side by side. Drag the scroll box and the normal counter races ahead while throttle ticks steadily and debounce waits for you to stop. Then try the two search inputs — the canonical case. Every keystroke in the plain input is an “API call”; the debounced one waits 500 ms of quiet and makes one:

let c1 = 0, c2 = 0; const fakeApi = () => {}; document.getElementById('s1').addEventListener('input', () => { fakeApi(); document.getElementById('c1').textContent = ++c1; }); document.getElementById('s2').addEventListener('input', debounce(() => { fakeApi(); document.getElementById('c2').textContent = ++c2; }, 500));

The resize section watches the window, so its counters only move when the window actually resizes — open the demo standalone and drag an edge to see it.

The browser's half: passive listeners

Rate-limiting fixes how often your code runs. There is a second cost you cannot fix with less code: for scroll and touch events, the browser must wait for your handler before moving the page, because you might call preventDefault(). Declaring the listener passive is a promise you won't:

area.addEventListener('scroll', onScroll, { passive: true });

With that promise made, scrolling never blocks on your JavaScript. The two techniques compose: passive tells the browser it may proceed without you; throttle and debounce decide how often you bother showing up at all.

Next Steps

That completes the event model: binding, the event object, flow, delegation, forms, your own events, and the firehose. Everything from here on uses it. In The DOM series, events are how every demo is driven — and the tree is what they drive:

  • The parse tree the browser builds from your markup
  • Selecting, walking, modifying, creating, and destroying nodes
  • Geometry, observers, and the libraries beyond the raw DOM