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.

This is how components talk. The demo's hub is a custom element (<simple-timer>), and that is no accident: a well-built web component communicates outward by dispatching custom events, never by reaching into the page around it. When you get to the Web Components series, this step is the vocabulary lesson for it. And since custom events bubble only if you ask (bubbles: true), a component can choose whether its announcements stay local or travel up the tree to delegated listeners — the flow rules from Step 3 apply to your events too.

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