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
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
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
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/removeEventListenercalls
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:
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:
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
eventPhaseto 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.eventPhaseto see which leg of the journey a listener caught - Halt further travel with
stopPropagation()— and distinguish it frompreventDefault()
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:
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:
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-actionattributes
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()andmatches() - Route behavior through
data-actionattributes 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:
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:
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.
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
submitand take over from the browser - React to
inputandchangeas 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
submiton the form — notclickon 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:
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:
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:
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 withdispatchEvent - 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':
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:
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, rapidinput - 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:
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:
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:
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